Hình dung nhiều người cùng ký vào một cuốn sổ chung. Cách an toàn: ai muốn ghi thì đưa dòng của mình cho một người thư ký, người này luôn viết vào đúng dòng trống cuối cùng bằng một động tác liền mạch không ai chen được — không hai dòng nào đè lên nhau. Cách hỏng: mỗi người tự bước tới, liếc thấy "dòng cuối đang là dòng 40", rồi quay về chỗ mình viết — nhưng trong lúc quay về, ba người khác đã kịp thêm dòng 41–45, và họ viết đè lên đúng dòng 41. Cái khe giữa "liếc vị trí" và "viết" chính là chỗ hỏng. Hai container gắn cùng một volume rồi cùng ghi vào một tệp cũng y như vậy — an toàn tới đâu? Tôi cho bốn container mỗi cái ghi 50.000 dòng, cùng lúc, và đếm lại.

Cách mở tệp Kết quả
O_APPEND 200.000 dòng, 0 dòng hỏng
lseek(SEEK_END) rồi write() 60.466 dòng nguyên vẹn, 271 dòng rách
Ghi có bộ đệm, chế độ "a" 200.000 dòng, 0 dòng hỏng

Dòng giữa mất gần 70% dữ liệu, và không có một thông báo lỗi nào. Mọi lời gọi write() đều trả về thành công.

Vì sao O_APPEND an toàn

Với cờ đó, nhân giữ khoá inode trong suốt một lời gọi write(): tìm cuối tệp và ghi là một thao tác không thể chen ngang. Tôi thử cả dòng dài 8.192 byte — vẫn 0 dòng hỏng.

Không có O_APPEND, ứng dụng phải tự làm hai bước:

os.lseek(fd, 0, os.SEEK_END)   # tim cuoi tep
os.write(fd, than)             # ghi

Giữa hai dòng đó, ba tiến trình kia kịp ghi thêm. Con trỏ mà bạn vừa tìm được đã cũ, và bạn ghi đè lên dữ liệu của người khác. Đó là lý do tổng số dòng tụt xuống chứ không phải tăng lên.

Chế độ "a" của Python (và của hầu hết ngôn ngữ) mở tệp với O_APPEND sẵn, nên nó an toàn — kể cả khi có bộ đệm ở giữa. Bộ đệm chỉ gom nhiều dòng lại thành một lời gọi write(), mà lời gọi đó vẫn nguyên tử.

Quy tắc rút ra: dùng chế độ "a"/O_APPEND và đừng bao giờ tự quản lý vị trí ghi trên tệp có nhiều bên cùng viết. Nếu thư viện của bạn cho chọn, hãy kiểm tra nó dùng cờ nào.

Sai lầm khi đo: bốn container không hề chạy cùng lúc

Lần đầu tôi chạy phép đo này, cả ba cách đều cho 200.000 dòng hoàn hảo. Tôi suýt kết luận rằng ghi đồng thời trên volume Docker vốn an toàn.

Nguyên nhân: docker run -d trả về ngay, nhưng container mất khoảng 300 mili giây để thật sự khởi động, trong khi việc ghi 5.000 dòng chỉ tốn ~10 mili giây. Bốn container lần lượt chạy xong, không cái nào gặp cái nào. Tôi đo tính đúng đắn của một chương trình chạy một mình.

Cách chữa là thêm hàng rào đồng bộ trên chính volume chung:

open(f"/chung/san-sang-{ten}", "w").close()      # bao la da san sang
while not os.path.exists("/chung/XUAT-PHAT"):    # cho hieu lenh
    time.sleep(0.01)

Phía ngoài chờ đủ bốn tệp san-sang-* rồi mới touch /chung/XUAT-PHAT. Cộng thêm việc tăng khối lượng lên 50.000 dòng mỗi bên, và sự khác biệt hiện ra ngay.

Rút ra một điều cho mọi phép đo tương tranh: nếu chưa chứng minh được hai bên chồng lên nhau về thời gian, bạn chưa đo tương tranh.

SQLite trên volume chung thì sao

Ba container cùng mở một tệp SQLite trên volume chung, mỗi cái chèn 3.000 dòng:

A: thanh cong=3000 loi=0
B: thanh cong=3000 loi=0
C: thanh cong=3000 loi=0
tong: 9000    toan ven: ok

Chạy tốt. Khoá tệp POSIX của SQLite hoạt động bình thường vì cả ba container dùng chung một nhân và một hệ thống tệp — với nhân, chúng chỉ là ba tiến trình như mọi tiến trình khác.

Điều này chỉ đúng với volume cục bộ. Trên ổ mạng thì khác hẳn, và đó là nội dung phần sau.

(Lần chạy đầu tôi dùng --rm nên container biến mất trước khi kịp đọc đầu ra, và tổng chỉ ra 3.000 — tôi không phân biệt được "B và C thất bại" với "B và C chưa chạy". Chạy lại có giữ log mới cho câu trả lời dùng được.)

Bốn cách cho hai container thấy cùng dữ liệu

Cách Đặc điểm Tốc độ ghi
Volume có tên dùng chung ghi hai chiều, sống lâu hơn container 1,0 GB/s
--volumes-from <container> mượn nguyên bộ volume của container khác như trên
--ipc=container:<X> chung /dev/shm 2,3 GB/s
--tmpfs không chia sẻ được —

--volumes-from mượn cả bộ. Tiện khi có một container "giữ dữ liệu" và nhiều container công cụ:

docker run --rm --volumes-from goc:ro alpine tar czf - /chung > sao-luu.tgz

Thêm :ro thì bên mượn không ghi được — đo thấy Read-only file system. Nên dùng cho mọi tiến trình chỉ cần đọc, như sao lưu.

tmpfs không đi theo. Tôi thử --volumes-from một container có --tmpfs /tam và bên kia không thấy gì. Đúng như phần 41 đã nói, tmpfs nằm ngoài .Mounts, nên mọi cơ chế dựa vào danh sách đó đều bỏ qua nó.

/dev/shm nhanh gấp đôi nhưng cần khai ở cả hai phía. Bên cho phải mở cửa trước:

docker run -d --name s1 --ipc=shareable --shm-size 64m ...
docker run --rm --ipc=container:s1 ...

Thiếu --ipc=shareable thì Docker từ chối thẳng thừng:

failed to join IPC namespace: non-shareable IPC
(hint: use IpcMode:shareable for the donor container)

Và lưu ý mặc định /dev/shm chỉ có 64 MB. Nhiều ứng dụng khoa học dữ liệu (PyTorch DataLoader chẳng hạn) hỏng đúng ở con số này với thông báo chẳng liên quan gì tới bộ nhớ chung — tăng bằng --shm-size.

Khi nào đừng chia sẻ

Chia sẻ tệp giữa các container là cách phối hợp thô sơ nhất, và nó không có thứ mà bạn thường cần: không thông báo khi có dữ liệu mới, không giao dịch, không hàng đợi, không biết ai đọc tới đâu.

Ba dấu hiệu cho thấy bạn đang dùng sai công cụ:

  • Có tiến trình phải hỏi đi hỏi lại thư mục xem có tệp mới chưa.
  • Phải nghĩ ra quy ước đặt tên kiểu .tmp rồi đổi tên để tránh đọc tệp đang ghi dở.
  • Hai bên cùng ghi, chứ không phải một ghi một đọc.

Cả ba đều là lúc nên dùng hàng đợi (Redis, RabbitMQ) hoặc CSDL. Chia sẻ volume hợp nhất với mô hình một bên ghi, nhiều bên đọc: sinh ảnh, sinh báo cáo, sinh tệp tĩnh.

Muốn tự kiểm ứng dụng của mình có mở tệp log đúng cách không, soi cờ của file descriptor:

docker exec <ten> sh -c 'ls -l /proc/1/fd' 2>/dev/null
docker exec <ten> sh -c 'cat /proc/1/fdinfo/1' 2>/dev/null | grep flags

Trường flags ở dạng bát phân; bit 02000 là O_APPEND. Không có bit đó mà lại có nhiều tiến trình cùng ghi thì bạn đang mất dữ liệu — và sẽ không có bất kỳ lỗi nào báo cho bạn biết.

Mẫu số chung

Bài học đầu tiên, đọc thẳng từ dòng mất 70% dữ liệu: một thao tác "xem rồi làm" trên trạng thái dùng chung là một cuộc đua, trừ khi hai bước ấy được gộp thành một thao tác nguyên tử — và cách chữa không phải tự đi khoá bằng tay, mà là tìm đúng cái nguyên hàm đã gộp sẵn hai bước. lseek tìm cuối tệp rồi write là hai bước, và cái khe giữa chúng cho ba tiến trình kia chen vào ghi đè; O_APPEND bảo nhân "tìm-cuối-và-ghi" trong một động tác giữ khoá inode, thế là hết đua. Cùng mẫu ấy ở khắp nơi: TOCTOU khi kiểm tra tệp rồi mới mở, SELECT thấy chưa có rồi mới INSERT (thay bằng INSERT ... ON CONFLICT), đọc-sửa-ghi một biến đếm (thay bằng atomic increment hay compare-and-swap), SELECT rồi UPDATE (thay bằng UPDATE ... WHERE). Nguyên tắc: khi nhiều bên cùng chạm một tài nguyên, đừng tách "kiểm tra" khỏi "hành động" — dùng nguyên hàm nào làm cả hai trong một nhịp, vì mọi cái khe bạn để hở giữa hai bước đều là chỗ một tiến trình khác lẻn vào.

Điều thứ hai, thứ khiến lỗi này đáng sợ: mọi lời gọi write() đều trả về thành công trong khi 70% dữ liệu bốc hơi — một lỗi đúng-đắn mà không hề kêu là loại nguy hiểm nhất. Mã trả về "ok" không chứng minh kết quả đúng; nó chỉ nói "thao tác được nhận", không nói "dữ liệu còn nguyên vẹn". Cùng cái bẫy "trả về OK mà kết quả sai" ở khắp nơi: giá trị trả về của close()/write() bị bỏ qua, gửi thông điệp fire-and-forget tưởng đã tới, một lệnh ghi cache âm thầm no-op, đọc-ngay-sau-ghi trên hệ nhất quán-cuối-cùng. Nguyên tắc: với thao tác mà tính đúng đắn không tự phát tín hiệu, phải đo kết quả (đếm lại số dòng) chứ đừng tin mã trả về — sự vắng mặt của lỗi không bao giờ là bằng chứng của sự đúng.

Phần sau gắn ổ mạng NFS vào container và đo xem cái gì còn dùng được.