Docker Bind Mount Permission Denied: UID/GID、Non-root 與 Rootless 的取捨
English Abstract: A classic Docker bind mount failure: the container writes to a host directory and gets Permission denied, even though the host user clearly owns that directory. The root cause is almost always a UID/GID mismatch between the host filesystem and the container process — Linux permission checks only look at numeric IDs, not usernames. This post compares three fixes: pinning the container to a specific UID with --user, the PUID/PGID entrypoint pattern popularized by LinuxServer.io images, and Rootless Docker / Podman's user namespace remapping (userns-remap), along with the security and portability trade-offs of each.
用 -v 把 host 目錄掛進 container,裡面的程式想寫入卻得到 Permission denied,即使在 host 上明明對這個目錄有讀寫權限,這是最常見的 bind mount 問題之一。問題幾乎都出在 host 檔案的 owner UID/GID,跟 container 內 process 實際跑的 UID/GID 對不上——Linux 的權限檢查只認數字,不認「使用者名稱」。這篇文章說明三種常見解法:用 --user 直接指定 UID、用 PUID/PGID entrypoint 動態對齊、以及 Rootless Docker/Podman 的 user namespace 重映射,並比較三者在安全性、可攜性與設定複雜度上的取捨。
(一)問題重現:host 目錄明明可寫,container 卻 Permission denied
典型情境:用以下指令把 host 目錄掛進 container:
docker run -d --name myapp -v /home/gz/data:/data myapp:1.0
進入 container 嘗試寫入:
docker exec -it myapp sh -c "echo hello > /data/test.txt"
sh: can't create /data/test.txt: Permission denied
而 host 上用自己的帳號檢查,明明有讀寫權限:
ls -la /home/gz/data
drwxr-xr-x 2 gz gz 4096 Oct 16 10:00 .
這種「host 看得到、container 寫不進去」的落差,根源幾乎都出在 UID/GID 沒對齊,下一節拆解原因。
(二)根本原因:UID/GID 數字對齊,而不是「名字」
Linux 檔案系統權限檢查只看 UID/GID 數字,不管 process 或 host 顯示出來的「使用者名稱」字串。Bind mount 跟 named volume 不一樣:它不做任何資料複製或初始化,是直接把 host 目錄原封不動掛進 container 的 mount namespace,權限位元(含 owner UID/GID)完全沿用 host 檔案系統上的原始數值。
多數 image 預設用 UID 0(root)或 Dockerfile 裡建立的某個固定 UID 跑 process,實際數值要自己查,不要用猜的:
docker run --rm myapp:1.0 id
uid=1000(node) gid=1000(node) groups=1000(node)
如果 host 目錄的 owner 是 UID 1001(你自己的帳號),而 container process 是 UID 1000,兩者對不上,就算 image 裡那個使用者「看起來」合理,一樣會 Permission denied。
先用兩道指令把兩邊的 UID 都查清楚,不要只看名字:host 用id -u/id -g,container 用docker run --rm <image> id。檢查 bind mount 權限問題,第一步永遠是對這兩個數字。
圖:Bind mount 下,host owner 與 container process 的 UID/GID 對齊關係——三種解法殊途同歸,核心都是讓兩邊數字一致
(三)解法一:--user 直接指定 UID(最快,適合單機開發)
最直接的做法是執行時用 --user 把 container process 的 UID/GID 直接指定成 host 目錄 owner 的數值:
id -u gz
1001
id -g gz
1001
docker run -d --name myapp --user 1001:1001 -v /home/gz/data:/data myapp:1.0
這樣 container 內 process 的 UID 跟 host 檔案 owner 完全一致,讀寫自然沒有權限問題。
缺點:image 裡如果沒有這個 UID 對應的使用者(/etc/passwd 查不到),某些依賴 $HOME、getpwuid() 的工具可能出錯,常見於需要寫 .cache、.config 的應用;而且這個數值綁死在某台 host 的帳號 UID,換一台機器、換一個使用者,UID 不同就要重新指定,不利於可攜性。
(四)解法二:Non-root 固定 UID + Entrypoint 動態對齊(PUID/PGID 模式)
如果不想每次都手動指定 --user,可以讓 image 自己在啟動時用 entrypoint script 把內部使用者的 UID/GID 動態改成外部傳入的環境變數,這是 LinuxServer.io 系列 image 普遍採用的做法。
Dockerfile 片段:
FROM alpine:3.20
RUN addgroup -g 1000 appgroup && adduser -D -u 1000 -G appgroup appuser
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
entrypoint.sh:
#!/bin/sh
set -e
PUID=${PUID:-1000}
PGID=${PGID:-1000}
groupmod -o -g "$PGID" appgroup
usermod -o -u "$PUID" appuser
exec su-exec appuser "$@"
啟動時只要傳入對應的 UID/GID:
docker run -d --name myapp -e PUID=1001 -e PGID=1001 -v /home/gz/data:/data myapp:1.0
優點:不用每次重新指定 docker run 參數,image 本身可攜,使用者只要帶兩個環境變數;而且 image 內 /etc/passwd 一定有這個使用者,不會有 getpwuid() 查不到的問題。
缺點:此機制需要 image 本身支援——若為自建 image,須自行撰寫 entrypoint;若使用現成 image,則須先確認官方文件是否已實作此模式,並非所有 image 皆支援。
(五)解法三:Rootless Docker/Podman 的 User Namespace 重映射
前兩種做法有個共同前提:container 裡的 process 本來就有能力變成跟 host 數值相同的 UID/GID,不管是直接指定還是啟動時 usermod。但這代表如果 container 被攻破,attacker 在 container 裡拿到的 UID,在 host 上就是同一個 UID——如果剛好是 host 上有意義的帳號,甚至是 UID 0,風險就被放大了。
Rootless 模式(Docker 的 userns-remap,或改用 Podman 的 rootless 預設模式)做法不同:container 內看到的 UID,跟 host 上實際對應的 UID,透過 /etc/subuid、/etc/subgid 做一層間接映射,container 內的 UID 0(root)實際上對應到 host 上一個完全沒有特殊權限的高位 UID(例如從 100000 起算的區間)。
啟用 Docker userns-remap(/etc/docker/daemon.json):
{
"userns-remap": "default"
}
重啟 daemon 後,確認映射範圍:
cat /etc/subuid
dockremap:100000:65536
這代表 container 內的 UID 0~65535,實際對應到 host 的 100000~165535。
Bind mount 的影響:host 目錄要能被 container 寫入,必須把 owner 改成映射後的對應 UID,而不是原本的邏輯 UID:
sudo chown -R 100000:100000 /home/gz/data
Podman 預設走 rootless,不需要額外設定 userns-remap,但同樣要注意 bind mount 目錄的 UID 要對應到 podman unshare cat /proc/self/uid_map 顯示的映射範圍,用 podman unshare chown 在正確的 namespace 下調整 owner,而不是直接在 host shell 下 chown。
優點是 container 內的 root 不等於 host 的 root,就算 container 被攻破、拿到 container 內的「root」,對 host 檔案系統實際上仍受限於一個低權限 UID,大幅降低 breakout 後的衝擊範圍。代價是 bind mount 的 UID 對齊變得不直覺,要先查映射表才知道該 chown 成什麼;部分需要跟 host network namespace、特殊 capability 互動的工作負載,在 rootless 模式下會受限或需要額外設定。
參考:Docker Docs - Isolate containers with a user namespace
(六)三種做法怎麼選
| 做法 | 安全性 | 可攜性 | 設定複雜度 | 適合情境 |
|---|---|---|---|---|
| --user 指定 UID | 中 | 低 | 低 | 本機開發、CI pipeline 裡短暫跑的 job |
| PUID/PGID entrypoint | 中 | 高 | 中 | 家用/小型自架服務,多台機器部署 |
| Rootless/userns-remap | 高 | 中 | 高 | 多租戶環境、對外服務、資安要求較高的 production |
結論
遇到 bind mount permission denied,照這個順序檢查:
- 先查清楚兩邊的 UID/GID 數字,host 用
id -u/id -g,container 用docker run --rm <image> id,不要只看使用者名稱 - 單機開發、CI 裡短暫跑的 job,用
--user直接指定最快 - 要長期跑、換機器部署的服務,PUID/PGID entrypoint 模式比較省心,但要先確認 image 有沒有支援
- 對外服務或多租戶環境,優先考慮 rootless/userns-remap,用一點設定複雜度換取 container root 不等於 host root 的安全邊界
- 用 rootless 模式時,bind mount 目錄的 owner 記得改成映射後的實際 UID,不是原本的邏輯 UID
延伸閱讀:Docker Container 錯誤排查指南・Docker 環境下的 Proxy 配置
常見問題 FAQ
為什麼用 named volume(docker volume create)就不會有這個問題,bind mount 卻會?
named volume 由 Docker 自己管理儲存位置與初始化,第一次掛載空的 named volume 時,Docker 會把 image 內該路徑既有的檔案複製過去,並且沿用 image 內該路徑原本設定的 owner(通常是 image 裡建立使用者時就設好的 UID),所以天生就是對齊的。bind mount 掛的是 host 既有目錄,owner 是 host 建立當下的 UID/GID,Docker 不會幫你做任何調整,對不對齊完全取決於你自己管理。
設定了 --user 之後,container 內還是有些檔案操作失敗,是什麼原因?
常見是 image 內其他目錄(例如 /tmp、使用者自己的 home、/var/run 底下的某些路徑)本身的 owner 是 build 時期設定好的其他 UID(通常是 root 或 image 預設使用者),跟你用 --user 指定的 UID 對不上,這些路徑本身也需要在 Dockerfile 裡用 RUN chown 處理,或是用 --user 以外再搭配 tmpfs 等方式避開。
可以同時用 rootless 模式又用 --user 指定 UID 嗎?
可以,兩者是不同層次的機制可以疊加:--user 決定的是 container 內 process 實際跑在哪個(namespace 內的)UID,userns-remap/rootless 決定的是這個 namespace 內的 UID 對應到 host 上的哪個實際 UID。疊加使用時,bind mount 要對齊的 host owner,要用「--user 指定的 UID 經過映射表轉換後」的數值,而不是原始數值,檢查起來要兩層一起看。
已經用 PUID/PGID 模式了,換了一台新機器後 bind mount 還是 permission denied,為什麼?
最常見原因是新機器上對應帳號的 UID 跟舊機器不同(同樣叫 gz 的帳號,在不同機器上是 UID 1000 還是 1001,取決於建立順序),而 docker run 指令裡的 PUID/PGID 環境變數值是沿用舊機器複製過來的,沒有依新機器實際 id -u/id -g 的結果更新。換機器部署時務必重新查一次目標機器的 UID/GID,而不是直接複製舊的啟動指令。
本文含聯盟連結。As an Amazon Associate I earn from qualifying purchases.
留言
張貼留言