Docker 基础体系 · 第 44/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker Volume 权限:UID/GID、umask、Rootless、SELinux 和共享

本文讨论 Linux 容器中的权限。这里的“Volume”既包括 Docker 管理的 named volume,也包括 bind mount;两者都把宿主机或 Docker 存储中的目录挂载到容器路径,但权限来源和生命周期并不相同。Windows 容器的权限模型、Windows ACL 和 Linux UID/GID 映射不在本文范围内。

1. 先建立权限模型:容器内的 UID/GID 不是用户名

Linux 文件权限首先看数字身份,而不是用户名字符串。

一个进程至少具有:

  • 有效 UID(effective UID):访问文件时用于判断“文件所有者”的数字身份。
  • 有效 GID(effective GID):访问文件时用于判断“文件所属组”的数字身份。
  • 补充组(supplementary groups):进程额外加入的组,用于判断组权限。
  • 能力(capabilities):将传统 root 权限拆分后的特权集合。
  • 用户命名空间映射(user namespace mapping):把容器内 UID/GID 映射到宿主机 UID/GID。

镜像中的 /etc/passwd/etc/group 只负责把数字 ID 显示成名字。例如:

容器内:uid=1000(app) gid=1000(app)
宿主机:uid=1000(alice) gid=1000(alice)

这两个 appalice 没有任何身份关联。对于普通 Unix 权限检查,重要的是数字 1000

因此,以下两个进程对同一个 bind mount 的访问结果可能完全不同:

容器 A:uid=1000 gid=1000
容器 B:uid=2000 gid=2000

即使两个容器内都显示用户名 app,宿主机上的文件仍然会把它们视为不同用户。

1.1 文件权限的选择顺序

假设文件的元数据为:

-rw-r----- 1 1000 2000 report.txt

权限位可以抽象为:

owner:rw-
group:r--
other:---

Linux 判断某个进程是否可读时,大致按以下顺序选择一组权限:

  1. 如果进程有效 UID 等于文件 UID,只检查 owner 权限;
  2. 否则,如果有效 GID 或补充组包含文件 GID,只检查 group 权限;
  3. 否则检查 other 权限。

重要边界是:命中 owner 后,不会因为 owner 权限不足而回退到 group 权限

例如:

文件:-rw-r----- 1 1000 2000 report.txt
进程:uid=1000 gid=2000

虽然进程也属于 GID 2000,但它已经命中了 owner 身份;此时 owner 权限为 rw-,不会使用 group 权限。

目录权限还需要特别注意:

  • r:能列出目录项名称;
  • w:能创建、删除和重命名目录项;
  • x:能穿过目录并访问其中已知名称的文件。

所以,一个进程可能能够读取某个已知路径下的文件,却不能列出父目录;也可能能够列目录但不能打开其中的文件。

1.2 root 并不等于绕过所有检查

传统 root 通常可以绕过大多数 Unix DAC(Discretionary Access Control,自主访问控制)限制,但这不是 Docker 权限的完整定义。

访问还可能被以下机制拒绝:

  • Linux capabilities;
  • 用户命名空间映射;
  • 只读挂载;
  • SELinux 等 LSM(Linux Security Module);
  • ACL;
  • NFS 服务端权限和 root squash;
  • 文件系统本身的限制。

因此,“容器里是 root”至少有三种不同含义:

  1. 容器内 UID 数字为 0
  2. 该 UID 在宿主机上也映射为真正的 root;
  3. 该进程拥有足够能力,且没有被 SELinux、文件系统或远端服务拒绝。

只有在传统 rootful Docker 且未受到其他限制时,这三者才比较接近。

2. Docker 挂载类型决定权限的来源

2.1 Bind mount:直接使用宿主机路径

mkdir -p "$PWD/data"

docker run --rm \
  --mount type=bind,src="$PWD/data",dst=/data \
  alpine sh -c 'id; touch /data/created.txt; ls -ln /data'

如果容器默认进程是 rootful Docker 中的 UID 0,created.txt 通常会在宿主机上显示为 UID 0、GID 0。这里的“通常”是因为实际结果仍受 rootless、用户命名空间、挂载选项和 SELinux 影响。

bind mount 的核心特点是:

  • 容器访问的是指定的宿主机目录;
  • 宿主机原有文件的 UID/GID 和 mode 直接可见;
  • 容器创建或修改文件,会直接改变宿主机文件;
  • 宿主机目录不会因为镜像中同名路径有内容而自动填充;
  • 挂载会遮挡容器镜像中目标路径原有内容。

最后一点经常造成误判:

docker run --rm \
  --mount type=bind,src="$PWD/empty",dst=/etc/example \
  image-that-contains-example-config

容器中 /etc/example 显示的是宿主机 empty 的内容,而不是镜像原本的内容。卸载挂载后,镜像文件仍然存在,但在容器运行期间不可见。

2.2 Named volume:由 Docker 管理生命周期

创建并使用 named volume:

docker volume create app-data

docker run --rm \
  --mount type=volume,src=app-data,dst=/data \
  alpine sh -c 'id; touch /data/file; ls -ln /data'

named volume 的数据由 Docker volume 管理。对于默认的 local volume,数据最终仍然位于宿主机文件系统中,但应用不应依赖某个固定宿主机路径,也不应在 volume 正被使用时直接绕过 Docker 去修改该目录。

named volume 首次挂载到一个空 volume时,Docker 通常会把镜像中目标路径已有的内容复制到 volume 中;复制时会保留文件的基本属性,包括常见的 UID/GID 和 mode。若使用 volume-nocopy,则禁止这次初始化复制:

docker run --rm \
  --mount type=volume,src=app-data,dst=/data,volume-nocopy \
  image-name

这不是“修复权限”的机制。它只控制初始化时是否复制镜像目录内容。

一个常见现象是:

镜像中的 /var/lib/app 属于 root:root
容器第一次挂载空 volume 到 /var/lib/app
volume 中的数据也因此属于 root:root
应用随后以 UID 1000 启动
结果:Permission denied

更可靠的做法是让镜像构建阶段就创建正确的目录,或由明确的初始化步骤执行 chown,而不是把权限修复寄托在 volume 自动初始化上。

2.3 tmpfs:内存中的临时文件系统

docker run --rm \
  --tmpfs /run/app:rw,nosuid,nodev \
  alpine sh -c 'mount | grep /run/app; touch /run/app/x'

tmpfs 的数据不进入 named volume,也不会持久化到宿主机普通目录。它仍然使用 Linux UID/GID、mode、umask 和 SELinux 等权限机制,但生命周期是容器或挂载生命周期。

因此,tmpfs 常用于:

  • 临时 socket;
  • PID 文件;
  • 短生命周期缓存;
  • 不希望写入持久化存储的中间数据。

它不是解决 UID/GID 不匹配的替代方案。

3. 镜像用户、运行时用户和 volume 初始化

一个镜像可以声明:

FROM alpine

RUN addgroup -S -g 1000 app \
 && adduser -S -D -u 1000 -G app app \
 && mkdir -p /var/lib/app \
 && chown -R 1000:1000 /var/lib/app

USER 1000:1000
VOLUME ["/var/lib/app"]

运行时可以再次覆盖用户:

docker run --rm \
  --user 2000:2000 \
  --mount type=volume,src=app-data,dst=/var/lib/app \
  image-name

此时镜像中的 USER 1000:1000 不再决定最终进程身份;--user 优先级更高。Compose 中对应的是:

services:
  app:
    image: example/app
    user: "1000:1000"

USER appuser: "1000:1000" 的区别在于:

  • USER app 依赖镜像内 /etc/passwd 能解析 app
  • user: "1000:1000" 直接使用数字身份,不要求容器内存在同名用户;
  • 宿主机权限判断最终仍基于数字 UID/GID。

3.1 一个可运行的权限失败示例

docker volume create demo-perm

docker run --rm \
  --mount type=volume,src=demo-perm,dst=/data \
  alpine sh -c 'mkdir -p /data/owned-by-root; chown 0:0 /data/owned-by-root; chmod 700 /data/owned-by-root'

docker run --rm \
  --user 1000:1000 \
  --mount type=volume,src=demo-perm,dst=/data \
  alpine sh -c 'id; touch /data/owned-by-root/x'

第二个容器通常输出:

uid=1000 gid=1000
touch: /data/owned-by-root/x: Permission denied

原因可以逐步展开:

  1. 目录所有者是 UID 0;
  2. mode 是 0700
  3. 访问者 UID 是 1000,不命中 owner;
  4. GID 也不匹配;
  5. other 权限为 ---
  6. 创建文件需要目录的 wx,两者都不存在。

如果初始化容器执行:

docker run --rm \
  --mount type=volume,src=demo-perm,dst=/data \
  alpine sh -c 'chown -R 1000:1000 /data'

之后,UID 1000 的容器就可能成功写入。但这个方案有两个边界:

  • 初始化容器必须拥有足够权限;
  • chown -R 可能修改大量已有数据,甚至破坏应用需要保留的所有权关系。

在 rootless Docker 中,第二个边界尤其重要,后文会说明。

4. umask:决定新建对象的默认权限,不修复已有文件

umask 是进程创建文件或目录时从请求权限中屏蔽权限位的掩码。它只影响新建对象,不能修改已有文件,也不能授予原本没有的权限。

对常见创建请求,计算关系可以写成:

最终 mode = 请求 mode & ~umask

其中:

  • 请求 mode 是程序传给 open(O_CREAT)mkdir 等系统调用的 mode;
  • umask 是进程的权限屏蔽位;
  • ~umask 表示取反后保留允许的位;
  • 最终 mode 是文件系统创建对象时使用的初始 mode。

例如,默认 umask 为 022

4.1 文件示例

普通程序创建文件时通常请求 0666,因为文件默认不应带执行位:

0666
& ~0022
= 0644

结果是:

-rw-r--r--

4.2 目录示例

程序创建目录时通常请求 0777

0777
& ~0022
= 0755

结果是:

drwxr-xr-x

如果共享目录需要组成员共同写入,常见目标是 umask 0002

文件:0666 & ~0002 = 0664
目录:0777 & ~0002 = 0775

这能保留 group 写权限,但前提是应用确实请求了 06660777。如果应用显式请求 0600,umask 不能把它“放宽”为组可读写。

4.3 容器中的 umask 来源

Docker 没有一个通用的 docker run --umask 参数来替所有应用设置 umask。umask 属于进程状态,通常由以下位置决定:

  • 进程自身调用 umask(2)
  • entrypoint shell 执行 umask 0002
  • shell 或启动器为子进程设置的环境;
  • 某些运行时或服务管理器的默认行为。

例如:

FROM alpine

RUN addgroup -S -g 1000 app \
 && adduser -S -D -u 1000 -G app app

USER 1000:1000
CMD ["sh", "-c", "umask 0002; exec sh"]

验证:

docker run --rm image-name sh -c '
  umask
  touch /tmp/a
  mkdir /tmp/d
  stat -c "%a %U:%G %n" /tmp/a /tmp/d
'

预期结果类似:

0002
664 app app /tmp/a
775 app app /tmp/d

具体用户名可能因镜像内容不同而显示为数字。

需要区分两个经常被混淆的事实:

  • umask 影响“创建时的默认 mode”;
  • UID/GID 和目录 mode 决定“当前是否能访问”。

如果 volume 中已有一个 0600、所有者为 UID 1000 的文件,把容器 umask 改成 0002 不会改变它。需要显式使用 chmodchown 或 ACL。

5. 多容器共享:挂载共享不等于协作安全

多个容器可以挂载同一个 named volume:

docker volume create shared-data

docker run -d --name writer \
  --mount type=volume,src=shared-data,dst=/data \
  alpine sh -c 'while :; do date >> /data/events.log; sleep 1; done'

docker run --rm \
  --mount type=volume,src=shared-data,dst=/data,readonly \
  alpine tail -n 5 /data/events.log

这里有三层不同问题。

5.1 能不能访问:由 UID/GID、mode、ACL 和 LSM 决定

如果 writer 以 UID 1000 创建:

-rw-r--r-- 1 1000 1000 events.log

另一个容器以 UID 2000 运行时,只能按 other 权限读取。如果文件是 0600,即使两个容器挂载了同一个 volume,第二个容器也无法读取。

共享身份通常需要以下条件同时成立:

访问者 UID/GID 与文件所有权或 ACL 匹配
且目录具有必要的 x/w/r 权限
且挂载不是只读
且 SELinux 等 LSM 允许访问

--group-add 可以加入补充组:

docker run --rm \
  --user 1000:1000 \
  --group-add 2000 \
  --mount type=volume,src=shared-data,dst=/data \
  alpine id

如果文件所属组为 GID 2000,且 group 权限允许访问,这个补充组才有意义。

5.2 创建后是否保持共享:需要 setgid 和合适的 umask

假设共享目录属于 GID 2000:

mkdir -p /srv/shared
chown 1000:2000 /srv/shared
chmod 2770 /srv/shared

这里的 2 是 setgid 目录位。它使得在目录中创建的新文件和子目录通常继承目录的组 GID 2000,而不是完全依赖创建者的主组。

容器内进程还需要使用组可写的 umask,例如:

umask 0002
目录请求 0777 -> 0775
文件请求 0666 -> 0664

一个典型共享条件是:

共享目录:GID 2000,mode 2770
容器进程:属于补充组 2000
进程 umask:0002
应用创建文件:不强制使用 0600

如果应用显式执行:

open("x", O_CREAT, 0600)

那么 setgid 和 umask 都不能把文件变成组可写。应用的创建策略仍然是决定因素。

5.3 能不能并发正确写入:需要应用层协议

Docker volume 不提供通用的跨容器事务、租约或文件锁服务。两个进程同时写同一个文件可能产生:

  • 追加内容交错;
  • 一个进程覆盖另一个进程的更新;
  • 临时文件重命名导致观察到不同版本;
  • 应用崩溃后留下半写入文件;
  • SQLite、日志、索引等数据格式损坏。

Linux 的 O_APPENDrename()flock() 等原语在特定条件下有用,但不能把任意“读—改—写”操作自动变成事务。对于数据库,通常应让数据库服务独占其数据目录;对于日志,应使用明确的日志聚合或单写者模型。

所以,以下两个命题完全不同:

两个容器都能访问 /data
两个容器可以安全地并发修改 /data/state.db

前者是权限和挂载问题,后者是应用并发一致性问题。

6. Rootless Docker:容器 root 和宿主机 root 分离

Rootless Docker 让 Docker daemon、容器进程和相关存储操作都尽量以普通宿主机用户运行。它依赖 Linux user namespace,把容器内的 UID/GID 映射到宿主机的 UID/GID 范围。

概念上可以表示为:

容器 UID 0       -> 宿主机启动 Docker 的用户 UID
容器 UID 1       -> 宿主机的 subordinate UID 范围中的某个 UID
容器 UID 1000    -> 另一个映射后的宿主机 UID

实际映射不是固定公式,必须以当前系统的 user namespace 映射为准。相关配置通常来自 /etc/subuid/etc/subgid,但具体分配由系统和 Rootless Docker 配置决定。

6.1 Rootless 下 bind mount 的直接后果

假设宿主机目录:

mkdir -p "$PWD/data"
chmod 700 "$PWD/data"

它属于宿主机用户 alice,并且只有 alice 可以访问。Rootless daemon 通常以 alice 身份运行,因此容器内的 rootless UID 0 可能能够访问该目录。

但如果目录属于另一个宿主机用户:

sudo chown 2000:2000 "$PWD/data"
sudo chmod 700 "$PWD/data"

那么容器内的 rootless UID 0 通常不能凭空绕过宿主机的 DAC。容器内显示:

uid=0(root)

并不表示它是宿主机 UID 0。

这正是 rootless 的安全边界:容器内 root 的权限被用户命名空间限制在宿主机普通用户和其映射范围内。

6.2 Rootless 下 named volume 的所有权观察

rootful Docker 中,volume 内由容器 UID 1000 创建的文件,宿主机上通常也能直接观察到 UID 1000。

Rootless Docker 中,容器 UID 1000 可能映射到 subordinate UID。于是宿主机工具可能显示一个看起来陌生的数字 UID,甚至显示为没有名称的用户。

不要根据“宿主机看到的 UID”直接反推容器内 UID。应在容器内验证:

docker run --rm \
  --mount type=volume,src=shared-data,dst=/data \
  alpine sh -c 'id; stat -c "%u:%g %a %n" /data'

也可以检查 Rootless Docker 使用的 daemon 数据目录和 namespace 映射,但不应假定 volume 一定位于某个固定路径,更不应在 Docker 正在使用时直接对内部存储目录执行 chown -R 或移动文件。

6.3 Rootless 下 chown 失败的原因

在 rootful 容器中,初始化脚本经常这样写:

chown -R 1000:1000 /data

在 rootless 环境中,该操作可能失败,原因包括:

  • 目标 UID/GID 不在当前 user namespace 的映射范围内;
  • 宿主机 bind mount 的所有权不允许当前用户修改;
  • 远端文件系统不支持所需的所有权操作;
  • NFS 服务端使用 root squash 或拒绝客户端的变更。

因此,初始化脚本不应无条件把 chown -R 当作启动前提。更稳妥的设计是:

  1. 在镜像构建阶段创建正确的应用用户和默认目录;
  2. 让数据目录由预先准备好的宿主机 UID/GID 管理;
  3. 只有确认当前身份能修改目标目录时才执行修复;
  4. 将修复失败作为明确错误,而不是静默继续运行。

6.4 Rootless 与远端存储

Docker 的 volume driver 或底层文件系统可能把权限判断放到 Docker 主机之外。例如 NFS 会由服务端根据客户端发送的 UID/GID、导出选项和 root squash 决定结果。

这会造成一个重要边界:

容器内看到的 UID/GID
≠ 必然是 NFS 服务端认可的身份

所以,rootless、NFS、ACL、Kerberos 或其他远端身份系统组合时,必须同时验证:

  • Docker 主机上挂载是否成功;
  • Docker daemon 用户是否能访问;
  • 服务端导出策略是否允许;
  • 容器进程的映射身份是否能读写;
  • 文件锁语义是否满足应用要求。

7. SELinux:Unix 权限通过后仍可能被拒绝

SELinux 是 Linux 的强制访问控制系统。传统 UID/GID 和 mode 属于 DAC;SELinux 根据进程和文件的安全上下文进行额外检查。

简化后的访问路径可以表示为:

flowchart TD
    A[容器进程发起 open/read/write] --> B[挂载与只读检查]
    B --> C[UID/GID、mode、ACL 进行 DAC 判断]
    C -->|通过| D[SELinux 等 LSM 判断]
    C -->|拒绝| E[Permission denied]
    D -->|允许| F[文件系统操作]
    D -->|拒绝| G[Permission denied 或 AVC 日志]

图中的顺序是帮助理解的简化模型;实际内核访问路径会涉及多个 hook 和文件系统细节。关键事实是:拥有正确 UID/GID 不保证 SELinux 一定允许访问

7.1 Bind mount 的 :z:Z

在启用 SELinux 的系统上,Docker 可以为 bind mount 调整文件的 SELinux label:

docker run --rm \
  -v "$PWD/data:/data:Z" \
  alpine sh -c 'touch /data/test'

常见语义是:

  • :z:将内容标记为可被多个容器共享的 SELinux 类型;
  • :Z:将内容标记为某个容器私有使用的类型。

它们不是 Unix chmod 的缩写,也不会改变 UID/GID。

例如,宿主机目录可能是:

UID/GID:1000:1000
mode:0770

即使这些 DAC 条件允许容器访问,错误的 SELinux label 仍可能导致:

touch: /data/test: Permission denied

使用 :z:Z 的风险在于它们会修改宿主机路径的安全上下文。对一个包含多个应用数据的宽泛目录执行 :Z,可能破坏其他服务对该目录的预期。生产环境中应只对明确的应用目录进行 relabel,并确认该路径可以接受这种变更。

7.2 SELinux 故障的诊断

先区分普通 DAC 失败和 SELinux 失败:

ls -ldZ ./data
ls -lZ ./data

在宿主机查看拒绝日志:

sudo ausearch -m avc -ts recent

某些发行版也可使用:

sudo journalctl -k | grep -i avc

诊断时应先确认:

  1. 容器挂载点是否正确;
  2. 容器内 id 是否是预期身份;
  3. stat 显示的 UID/GID 和 mode;
  4. 宿主机路径的 SELinux context;
  5. 是否使用了 :z:Z
  6. 宿主机是否真的启用了 SELinux。

临时关闭 SELinux 或把系统切换到宽松模式可以用于定位,但不应作为权限修复方案,因为这会改变整台主机的安全边界。

8. readonly、文件系统挂载和“只读”的边界

容器挂载可以设置为只读:

docker run --rm \
  --mount type=volume,src=shared-data,dst=/data,readonly \
  alpine sh -c 'touch /data/x'

预期会失败,常见错误类似:

touch: /data/x: Read-only file system

这与普通 Unix 权限失败不同:

Permission denied

只读挂载是挂载层面的限制;它不是把文件 mode 改成 0444,也不会修改 volume 中原有文件权限。其他容器如果以读写方式挂载同一个 volume,仍可能修改数据。

同样,容器内执行 chmodchown 的结果也取决于实际挂载和身份:

  • 文件系统只读时,通常会失败;
  • 进程不是所有者且没有相应能力时,会失败;
  • rootless 映射不包含目标 ID 时,可能失败;
  • NFS 等远端文件系统可能依据服务端策略失败。

9. 一个可复用的 Compose 共享示例

下面的 Compose 配置演示两个服务以相同数字用户和共享组访问同一个 named volume:

services:
  writer:
    image: alpine:3.20
    user: "1000:1000"
    group_add:
      - "2000"
    command:
      - sh
      - -c
      - |
        umask 0002
        mkdir -p /data/logs
        while true; do
          date >> /data/logs/app.log
          sleep 5
        done
    volumes:
      - shared-data:/data

  reader:
    image: alpine:3.20
    user: "1001:1001"
    group_add:
      - "2000"
    command:
      - sh
      - -c
      - |
        umask 0002
        while true; do
          tail -n 5 /data/logs/app.log 2>/dev/null || true
          sleep 5
        done
    volumes:
      - shared-data:/data

volumes:
  shared-data:

这个配置不能单独保证成功,因为 named volume 第一次创建时,其根目录的所有权和 mode 仍可能不符合预期。可以先使用一次性初始化容器设置目录:

docker compose run --rm \
  --user 0:0 \
  writer sh -c '
    mkdir -p /data/logs
    chown -R 1000:2000 /data
    chmod 2770 /data /data/logs
  '

然后启动服务:

docker compose up

其成立条件是:

  1. 初始化容器的 UID 0 有权修改该 volume;
  2. /data/logs 的组为 2000;
  3. 目录 mode 含有 setgid 和组写权限;
  4. writer 和 reader 都通过 group_add 加入 GID 2000;
  5. 应用实际创建的文件没有被强制设为过于严格的 mode;
  6. Docker 运行环境没有额外的 SELinux 或远端存储限制。

在 rootless Docker 中,第 1 步不一定成立。若 volume 中的目录由 rootless daemon 管理,容器内 UID 0 仍然只是 namespace 内的 root,不能把它当作宿主机 root 使用。

10. 诊断权限问题的固定路径

面对 Permission denied,不要首先修改成 chmod 777。应逐层确认。

10.1 确认挂载对象

docker inspect container-name \
  --format '{{json .Mounts}}'

重点检查:

  • Typebindvolume 还是 tmpfs
  • Source 是否为预期路径或 volume;
  • Destination 是否覆盖了镜像原有目录;
  • RW 是否为 true
  • 是否设置了 SELinux 相关选项。

10.2 确认容器进程身份

docker exec container-name id
docker exec container-name sh -c 'cat /proc/self/status | grep -E "Uid|Gid|Groups"'

确认:

  • 有效 UID;
  • 有效 GID;
  • 补充组;
  • 是否与文件的数字 UID/GID 匹配。

10.3 查看文件而不是只看目录

docker exec container-name \
  stat -c 'mode=%A numeric=%a uid=%u gid=%g path=%n' /data /data/file

docker exec container-name \
  namei -l /data/file

namei -l 可以逐级显示路径组件权限。很多故障不是文件本身的问题,而是父目录缺少 x,导致无法穿过目录。

10.4 分别测试读、写、创建和删除

docker exec container-name sh -c '
  test -r /data/file; echo "read=$?"
  test -w /data;      echo "dir-write=$?"
  test -x /data;      echo "dir-traverse=$?"
  touch /data/.perm-test
  rm /data/.perm-test
'

“能写文件”与“能删除文件”并不等价:

  • 修改文件内容主要看文件自身写权限;
  • 删除目录项主要看父目录的写和执行权限。

10.5 确认宿主机和 SELinux

对于 bind mount:

stat -c 'mode=%A numeric=%a uid=%u gid=%g path=%n' ./data
ls -ldZ ./data

对于 named volume,可以查看逻辑信息:

docker volume inspect app-data

不建议直接依赖 inspect 输出的宿主机内部路径进行生产操作,尤其是 Rootless Docker、不同存储驱动和远端 volume driver 环境。

如果 DAC 看起来都正确,而错误仍是 Permission denied,检查 AVC 日志和挂载标签。

11. 备份与恢复时,必须保留或重建所有权

权限问题经常在恢复后重新出现。只复制文件内容而不保留 UID/GID、mode、ACL 或 SELinux context,可能让恢复的数据无法被应用使用。

一个简单的 named volume 备份示例:

mkdir -p backup

docker run --rm \
  --mount type=volume,src=app-data,dst=/data,readonly \
  --mount type=bind,src="$PWD/backup",dst=/backup \
  alpine \
  tar czpf /backup/app-data.tar.gz -C /data .

这里:

  • 第一个挂载以只读方式访问 volume;
  • 第二个挂载把宿主机 backup 目录提供给临时容器;
  • tarp 选项要求保留权限信息;
  • 该命令仍需要考虑容器内 tar 的能力、ACL、xattr 和 SELinux context 是否被完整保存。

恢复时:

docker run --rm \
  --mount type=volume,src=app-data,dst=/data \
  --mount type=bind,src="$PWD/backup",dst=/backup \
  alpine \
  tar xzpf /backup/app-data.tar.gz -C /data

恢复前应停止会修改该 volume 的应用,避免产生并发写入。否则即使 tar 成功,也可能得到时间点不一致的数据集。

如果目标环境的 UID/GID 与备份来源不同,恢复后有两种策略:

  • 保留原数字 UID/GID,并在目标环境创建相应用户或组;
  • 恢复后明确执行一次经过验证的 UID/GID 转换。

不要只根据用户名转换,因为用户名在两个镜像或两个主机上可能对应不同数字 ID。

12. 常见误解与反例

误解一:容器里创建 app 用户,宿主机就认识这个用户

反例:

容器:app -> UID 1000
宿主机:alice -> UID 1000

这两个用户名称不同,但普通文件权限判断仍可能允许访问,因为数字 UID 相同。反过来,即使名称都叫 app,只要数字 UID 不同,也可能无法访问。

误解二:umask 0002 能让已有文件变成组可写

反例:

已有文件:0600
修改 umask:0002

文件仍然是 0600。umask 只在创建时参与计算,不能改变已有 mode。

误解三:容器内 root 一定能 chown

在 Rootless Docker、bind mount 到无权目录、NFS root squash 或不支持该操作的文件系统上,容器内 UID 0 可能无法完成 chown

误解四:共享 volume 会自动提供锁和事务

volume 只提供共享的文件系统视图。它不会自动解决多个应用同时更新同一个状态文件产生的数据竞争。

误解五:chmod 777 可以解决所有 Docker volume 问题

它无法解决:

  • SELinux label 拒绝;
  • 只读挂载;
  • rootless 用户命名空间限制;
  • NFS 服务端拒绝;
  • 应用并发写入;
  • 父目录不可穿越;
  • 文件系统错误或配额限制。

它还会扩大不必要的写入范围,增加同机其他进程修改数据的风险。

13. 权限设计的核心边界

可以把 Docker volume 的可写条件概括为:

进程能够写入路径
⇔
挂载层允许写入
且路径中每一级目录可穿越
且目标操作所需的 UID/GID、mode、ACL 条件满足
且 rootless 映射允许该身份执行操作
且 SELinux 等 LSM 允许
且底层文件系统或远端服务允许

对于新建文件,还要额外考虑:

新文件 mode = 应用请求 mode & ~umask

对于多容器共享,还要再增加:

权限可访问
≠
并发语义正确

工程上真正需要先确定的是数据目录的身份契约:

哪个数字 UID 写入?
哪个数字 GID 共享?
新文件需要哪些 mode?
是否需要 setgid 或 ACL?
是否运行在 rootless?
是否启用 SELinux?
是否是本地文件系统还是远端存储?
是否允许多个进程并发修改同一数据结构?

只有这些条件同时明确,USER--usergroup_add、umask、初始化步骤和 volume 挂载选项才会形成可验证的权限方案,而不是靠反复尝试 chmodchown


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。