Hình dung một bãi giữ xe chỉ ghi biển số lên vé, tuyệt đối không ghi tên. Cái khoá, cái barie chỉ biết đúng con số đó. Còn "xe này của ai" thì nằm trong một cuốn sổ tra cứu — mà bãi trong nhà và bãi ngoài sân lại giữ hai cuốn sổ khác nhau. Cùng biển số "0", sổ trong nhà tra ra "sếp", sổ ngoài sân tra ra một người lạ. Quyền tệp giữa container và máy chủ đúng là cái bãi xe ấy: nhân Linux chỉ đóng một con số (uid) lên mỗi tệp, còn "root" hay "lan" chỉ là tên mà mỗi bên tự tra trong cuốn /etc/passwd của riêng mình.

Triệu chứng quen thuộc: bạn bind mount thư mục dự án vào container, chạy build, rồi quay lại máy chủ và không sửa được chính tệp của mình nữa.

$ ls -l /nha/lan/du-an
drwxr-xr-x  2 root root  4096  dist
-rw-r--r--  1 root root    14  ket-qua.txt

$ echo them >> ket-qua.txt
sh: can't create ket-qua.txt: Permission denied

$ rm -rf dist
rm: can't remove 'dist/a.js': Permission denied

Thư mục du-an thuộc về lan. Nhưng những gì container tạo ra bên trong nó thì thuộc về root, và lan chỉ đọc được chứ không đụng vào được.

Phép đo trong bài này chạy trên daemon Linux. Trên Docker Desktop macOS bạn sẽ không thấy vấn đề, vì lớp fakeowner (phần 41) làm quyền luôn khớp. Đó chính là lý do lỗi này hay xuất hiện lần đầu ngay trên CI hoặc máy chủ thật.

Vì sao xảy ra

Không có phép dịch nào cả. Container không có "hệ thống người dùng" riêng biệt với nhân — nó dùng chung nhân với máy chủ, và nhân chỉ ghi một con số xuống inode. Tiến trình chạy bằng uid 0 thì tệp mang uid 0, bất kể trong container cái tên đó là root còn ngoài máy chủ nó là ai.

Cái tên root, lan, www-data chỉ tồn tại trong /etc/passwd, và container có /etc/passwd riêng. Hai bên nhìn cùng một con số và gọi bằng hai cái tên khác nhau.

Chiều ngược lại cũng hỏng

Chạy container bằng uid khác thì đến lượt container không ghi được:

Chạy với Ghi vào thư mục của lan (uid 1500)
--user 0 ghi được (root vượt qua mọi kiểm tra)
--user 1000 Permission denied
--user 1500 ghi được

Đây là dạng thường gặp hơn trên CI: image dựng sẵn chạy bằng node (uid 1000), còn thư mục checkout thuộc về một uid khác, và job hỏng ở bước ghi tệp đầu tiên.

Ba cách vá, đo cả ba

Tôi để cả ba tạo tệp trong cùng một thư mục rồi kiểm tra lan có sửa được không:

[1] Chỉ định uid lúc chạy

docker run --user "$(id -u):$(id -g)" -v "$PWD":/app ...

[2] Cố định uid trong Dockerfile

RUN adduser -D -u 1500 ung-dung
USER ung-dung

[3] Chạy root rồi chown lại trong entrypoint

chown -R 1500:1500 /app
Cách Chủ tệp trên máy chủ lan sửa được?
[1] --user 1500:1500 lan lan ✅
[2] USER trong Dockerfile lan lan ✅
[3] chown trong entrypoint lan lan ✅

Cả ba đều cho kết quả đúng. Khác nhau ở chỗ dùng khi nào:

  • [1] linh hoạt nhất cho môi trường phát triển, vì uid lấy từ chính người đang gõ lệnh. Nhưng nó phá cách [2], và có cái bẫy ở mục dưới.
  • [2] chắc chắn nhất cho môi trường thật, nhưng bạn phải chọn được một con số cố định và mọi máy chủ phải dùng đúng con số đó.
  • [3] chỉ dùng khi buộc phải khởi động bằng root (ví dụ cần bind cổng đặc quyền — mà phần 31 đã chỉ ra là thường không cần). Nhớ exec sang người dùng thường sau đó, đừng chạy tiếp bằng root.

Cái bẫy của --user: uid không có tên

whoami : whoami: unknown uid 1500
id     : uid=1500 gid=1500 groups=1500
HOME   : /
ghi vao $HOME: Permission denied

--user 1500 chỉ đặt con số. Trong container không có dòng nào trong /etc/passwd cho uid đó, nên:

  • whoami lỗi — và nhiều script khởi động gọi nó.
  • $HOME thành /, không ghi được. Công cụ nào cần thư mục nhà (npm, pip, git, ssh) sẽ hỏng ở một chỗ nghe chẳng liên quan gì tới quyền.

Cách chữa gọn nhất là gắn thêm một /etc/passwd tối thiểu, hoặc đặt HOME sang thư mục ghi được:

docker run --user "$(id -u):$(id -g)" \
  -e HOME=/tmp \
  -v /etc/passwd:/etc/passwd:ro \
  ...

Cách thứ tư: để daemon dịch giúp

userns-remap bật user namespace ở mức daemon. Container vẫn thấy mình là root, nhưng nhân cộng thêm một khoảng dịch trước khi ghi xuống đĩa:

trong container: uid=0(root) gid=0(root)
tren may chu   : -rw-r--r-- 1 165536 165536 ... /nha/du-an/f
dai uid duoc cap: dockremap:165536:65536

Container root ghi tệp, và tệp đó thuộc về uid 165536 — một tài khoản không tồn tại, không có quyền gì trên máy chủ. Đây là hàng rào bảo mật thật: thoát khỏi container cũng không thành root của máy.

Bật bằng /etc/docker/daemon.json:

{ "userns-remap": "default" }

Đổi lại, nó làm bài toán bind mount khó hơn, không dễ hơn: giờ bạn phải chown thư mục trên máy chủ sang uid 165536+ thì container mới ghi được. Nó giải quyết vấn đề bảo mật, không giải quyết vấn đề tiện dụng — và nó lặp lại đúng logic của Docker rootless ở phần 32.

Volume không có vấn đề này

Nhắc lại từ phần 41: volume thừa hưởng quyền sở hữu của thư mục trong image. Ứng dụng chạy bằng uid nào thì Docker đã dựng sẵn thư mục cho uid đó, và bạn không phải làm gì cả.

Vì vậy quy tắc thực dụng: dữ liệu của ứng dụng thì dùng volume, còn bind mount để dành cho mã nguồn khi đang phát triển — nơi bạn thật sự cần nhìn thấy tệp trên máy mình.

Tự kiểm trên máy bạn

mkdir -p thu && docker run --rm -v "$PWD/thu":/x alpine touch /x/tu-container
ls -l thu/
  • Thấy root root → bạn đang ở Linux và sẽ gặp đúng vấn đề trong bài này.
  • Thấy tên bạn → hoặc bạn đang ở Docker Desktop, hoặc daemon đã bật userns-remap.

Khi CI báo Permission denied ở một bước ghi tệp, ba câu hỏi theo thứ tự:

  1. Container chạy bằng uid nào? docker run ... id
  2. Thư mục trên máy chủ thuộc uid nào? ls -ldn
  3. Hai con số đó có bằng nhau không?

Gần như mọi lần, câu trả lời nằm ở câu hỏi thứ ba.

Mẫu số chung

Bài học đầu tiên, chính là cái bãi xe ghi biển số: danh tính mà hệ thống thật sự lưu là một con số trần, còn cái tên chỉ là kết quả tra cứu trong một bảng cục bộ của từng phía — nên "root" của bên này không nhất thiết là "root" của bên kia. Ranh giới thật nằm ở con số uid, không ở chữ. Cùng cái bẫy "cùng con số, khác nghĩa" ở khắp nơi: một enum lưu dưới dạng số nguyên trong CSDL mà hai ứng dụng ánh xạ ra hai tên khác nhau, một mốc thời gian không mang múi giờ bị hai bên diễn giải lệch nhau bảy tiếng, một byte 0xC3 hiện ra chữ khác nhau tuỳ bảng mã, một khoá chính trùng số nhưng ở hai shard là hai thực thể. Nguyên tắc: khi một ranh giới chỉ truyền đi con số/định danh trần, thứ phải khớp giữa hai bên là bảng tra nghĩa, không phải cái tên — hãy thống nhất bảng ánh xạ ở đúng ranh giới đó, đừng tin rằng cái tên tự mang nghĩa theo cùng nó qua biên.

Điều thứ hai, đọc từ dòng cảnh báo "macOS không thấy vấn đề, Linux thì có": máy phát triển của bạn thường có một lớp làm cho dễ âm thầm che mất ngữ nghĩa thật, nên lỗi chỉ lộ ra khi chạy trên môi trường không có lớp che đó. fakeowner của Docker Desktop khớp quyền hộ bạn, tới lúc lên CI/máy chủ Linux thật — nơi không có nó — mới vỡ. Cùng cái bẫy "lớp che ở dev" ở khắp nơi: "chạy tốt trên máy tôi" vì máy tôi có shim, SQLite ở dev nhưng Postgres ở prod, hệ tệp không phân biệt hoa-thường của macOS che lỗi mà Linux phân biệt sẽ vấp, một bộ phân tích dễ dãi ở local nuốt cái mà server nghiêm khắc từ chối. Nguyên tắc: mỗi khi thấy một tiện ích ở môi trường phát triển "tự lo giúp" một chuyện, hãy coi đó là một lớp che ngữ nghĩa — và kiểm lại trên môi trường không có lớp che ấy trước khi tin rằng nó thật sự chạy đúng, vì cái chạy ngon ở đây thường chỉ đang được che khuất chỗ hỏng.

Phần sau đo ba cách sao lưu volume, và cái bẫy khi sao lưu một CSDL đang chạy.