Phần 46 đào vào chuyện Docker Desktop chiếm bao nhiêu đĩa thật. Phần 47 quay lại câu hỏi thực dụng hơn: hai container cần dùng chung dữ liệu thì nối chúng lại bằng cách nào, và cách nào trong số đó âm thầm làm mất dữ liệu khi cả hai cùng ghi một lúc. Ba cách phổ biến là volume dùng chung (mount cùng một volume ở hai container), --volumes-from (kiểu cũ, mượn mount của container khác), và tmpfs. Bài này đo cả ba, rồi đo tiếp phần quan trọng hơn: khi hai tiến trình cùng ghi vào đúng một file, dữ liệu hỏng ở đâu và hỏng bao nhiêu.
Ba cách nối container lại với nhau
Volume dùng chung là cách chuẩn: tạo một named volume, mount vào bao nhiêu container tuỳ ý.
docker volume create demo-chia-se
docker run --rm -v demo-chia-se:/data alpine:3.20 sh -c 'echo "tu A" > /data/f.txt'
docker run --rm -v demo-chia-se:/data alpine:3.20 cat /data/f.txt
# tu A
--volumes-from là cách cũ hơn Compose, vẫn hoạt động: container B mượn nguyên các mount của container A, kể cả anonymous volume không có tên.
docker create --name data-container -v /data alpine:3.20
docker run --rm --volumes-from data-container alpine:3.20 sh -c 'echo x > /data/f.txt'
docker run --rm --volumes-from data-container alpine:3.20 cat /data/f.txt
# x
docker inspect data-container cho thấy đây không phải sao chép — cả hai container trỏ thẳng vào cùng một thư mục vật lý trên host (/var/lib/docker/volumes/<id>/_data). Không có gì huyền bí ở --volumes-from, nó chỉ là cú pháp gọn cho việc mount lại đúng volume của container khác.
tmpfs KHÔNG chia sẻ được giữa các container — đây là chỗ dễ hiểu lầm nhất. Mỗi container dùng --tmpfs /data nhận một vùng RAM riêng, dù đường dẫn mount trùng nhau:
docker run --rm --tmpfs /data alpine:3.20 sh -c 'echo x > /data/f.txt; ls /data'
# f.txt
docker run --rm --tmpfs /data alpine:3.20 sh -c 'ls /data; cat /data/f.txt'
# (thư mục rỗng)
# cat: can't open '/data/f.txt': No such file or directory
Đo trên máy đang viết bài (Docker 29.7.2): container thứ hai không thấy f.txt đâu cả. tmpfs hợp lý cho cache tạm hoặc giấu secret không muốn ghi xuống đĩa, không hợp cho việc chia sẻ giữa nhiều container — dù cú pháp mount trông giống hệt volume.
Còn bind mount (mount thẳng một thư mục host) thì phần 42 đã đo hiệu năng và phần 43 đã đo lệch UID — không lặp lại ở đây, chỉ lưu ý bind mount cũng chia sẻ được y hệt volume, đổi lại phải tự lo quyền file.
Khi hai tiến trình cùng ghi: đo race condition
Ba cách trên đều trả lời được câu "làm sao container B đọc được thứ container A ghi". Câu khó hơn là "hai container cùng ghi một lúc thì sao". Đo bằng kịch bản kinh điển: một file đếm số, hai container cùng đọc — cộng 1 — ghi lại, mỗi bên lặp 3000 lần, không khoá gì cả.
# race.sh chạy trong cả hai container, cùng mount demo-chia-se
FILE=/data/counter.txt
i=0
while [ $i -lt 3000 ]; do
n=$(cat "$FILE")
n=$((n + 1))
echo "$n" > "$FILE"
i=$((i + 1))
done
Chạy một mình, race.sh cho đúng 3000/3000 trong 0,78 giây — không có gì bất ngờ. Chạy hai container song song, nhắm vào cùng file, lặp lại 4 lần độc lập với bộ đếm được reset về 0 mỗi lần: kết quả cuối cùng lần lượt là 63, 68, 47, và 1 — trên tổng 6000 lần cộng lẽ ra phải có. Lần tệ nhất chỉ ghi nhận đúng 0,02% số lần cộng thật sự xảy ra.
Lần cho kết quả 1 còn gặp lỗi thật trong log: arithmetic syntax error ở dòng n=$((n+1)), hai lần liên tiếp. Nguyên nhân: echo "$n" > "$FILE" không phải một thao tác nguyên tử — nó mở file ở chế độ ghi đè (truncate) rồi mới ghi nội dung mới, hai bước tách rời. Nếu container kia đọc đúng lúc file vừa bị truncate nhưng chưa kịp ghi xong, nó nhận về một chuỗi rỗng, và $((n+1)) với n rỗng là lỗi cú pháp shell — không phải lỗi logic, mà chương trình đọc trúng dữ liệu nửa vời đang được ghi.
Đây là hai kiểu hỏng khác nhau đáng phân biệt: mất cập nhật (lost update — ghi đè lên nhau, counter thấp hơn nhiều so với mong đợi) và đọc dữ liệu dở dang (torn read — đọc trúng file đang bị truncate giữa chừng). Docker không gây ra cả hai; chúng xảy ra vì nhiều tiến trình đụng vào cùng một file mà không có cơ chế loại trừ lẫn nhau — volume chỉ cho thấy file dùng chung, không tự thêm khoá nào.
Ghi nối (append) lại an toàn hơn tưởng
Không phải mọi kiểu ghi đồng thời đều hỏng. Đổi kịch bản: hai container cùng ghi nối (>>) vào một file log, mỗi bên 3000 dòng ngắn, không khoá:
# container A
echo "A-dong-$i-du-lieu-demo" >> /data/log.txt
# container B (chạy song song)
echo "B-dong-$i-du-lieu-demo" >> /data/log.txt
Đo lại: file cuối cùng có đúng 6000/6000 dòng, khớp mẫu ^[AB]-dong-[0-9]+-du-lieu-demo$ tuyệt đối — 0 dòng bị trộn hoặc cắt cụt. Lý do không phải may mắn: >> mở file với cờ O_APPEND, và với một dòng ngắn, kernel Linux ghi trong đúng một lời gọi write() — POSIX đảm bảo lời gọi write() với O_APPEND là nguyên tử trên cùng một file, hai tiến trình không thể xen giữa lời gọi của nhau. Khác hẳn kịch bản counter ở trên, vốn cần đọc rồi tính rồi ghi lại — ba bước tách rời, không có gì đảm bảo nguyên tử giữa chúng.
Điều kiện để giữ được sự an toàn này: dòng ghi phải đủ ngắn (Linux đảm bảo nguyên tử cho ghi dưới kích thước PIPE_BUF, thường 4096 byte — dòng dài hơn không còn được đảm bảo), và filesystem phía sau phải hỗ trợ đúng ngữ nghĩa O_APPEND cục bộ. Test này chạy trên volume driver local của Docker Desktop; chưa đo trên volume gắn qua NFS hay driver mạng khác — đừng suy diễn kết quả này áp dụng y hệt ở đó.
Khoá bằng flock: đúng, nhưng chậm hơn hẳn
Sửa kịch bản counter bằng flock, khoá quanh đúng ba bước đọc–tính–ghi:
(
flock 9
n=$(cat "$FILE")
n=$((n + 1))
echo "$n" > "$FILE"
) 9>/data/counter.lock
Kết quả: đúng 6000/6000, không sai một đơn vị. Đổi lại mất 1,98 giây, so với khoảng 0,9 giây của cặp không khoá (nhanh hơn không phải vì "hiệu quả" hơn — mỗi bên vẫn lặp đủ 3000 vòng, chỉ là phần lớn kết quả ghi đè lên nhau nên không tốn công chờ). 1,98 giây khớp gần đúng với 2 × 0,78 giây (thời gian chạy một mình) cộng chi phí khởi động container — hợp lý, vì khoá buộc hai tiến trình chạy tuần tự trên đoạn găng, không còn song song thật.
Bảng tổng hợp
| Cách chia sẻ | Container khác thấy được? | Ghi đồng thời an toàn? |
|---|---|---|
Volume dùng chung (-v) |
Có | Chỉ với ghi nối ngắn; đọc–tính–ghi cần tự khoá |
--volumes-from |
Có (mượn mount của container khác) | Giống hệt volume phía dưới, không thêm bảo vệ nào |
tmpfs |
Không — mỗi container một bản riêng trong RAM | Không áp dụng, vì không dùng chung |
| Bind mount | Có (xem phần 42, 43) | Giống volume, cộng thêm rủi ro lệch UID |
Khi nào đừng tự dựng bằng file dùng chung
flock sửa được đúng kịch bản một file, một máy. Nó không mở rộng ra nhiều host (volume local không đồng bộ qua mạng), không chịu được tiến trình bị kill giữa chừng khi đang giữ khoá (flock không timeout sẽ treo mãi chờ), và không cho đọc pha trộn với ghi mà vẫn nhất quán. Nếu nhu cầu thật là "nhiều container cùng đọc/ghi trạng thái dùng chung, đúng và nhanh", nên trỏ vào một dịch vụ thiết kế cho việc đó — Postgres, Redis — thay vì để nhiều tiến trình tranh nhau một file thô trên volume. Volume dùng chung hợp nhất cho đọc-nhiều-ghi-ít (một container ghi, còn lại chỉ đọc) hoặc mỗi container sở hữu một phần riêng biệt (ghi vào file tên khác nhau trong cùng thư mục).
Thử ba mươi giây
Tự thấy lost update trên máy đang có Docker:
docker volume create demo-chia-se
docker run --rm -v demo-chia-se:/data alpine:3.20 sh -c 'echo 0 > /data/c.txt'
for x in 1 2; do
docker run --rm -v demo-chia-se:/data alpine:3.20 sh -c '
i=0; while [ $i -lt 2000 ]; do
n=$(cat /data/c.txt); n=$((n+1)); echo "$n" > /data/c.txt; i=$((i+1))
done' &
done
wait
docker run --rm -v demo-chia-se:/data alpine:3.20 cat /data/c.txt
# mong đợi 4000, thường ra một con số rất nhỏ
Bài viết liên quan
- docker system df báo 2,213GB, đĩa host thật mất 3,32GB: giải phẫu file VM sparse của Docker Desktop
- UID 1000 ghi được, UID 2000 đọc bị từ chối ngay lập tức: đo lệch quyền tệp giữa container và host, vá bằng ACL và group thay vì chown
- Bind mount ghi ngẫu nhiên 4K nhanh gấp 4 lần volume, nhưng thua khi ghi tuần tự: đo I/O theo bốn kiểu truy cập