Docker Compose 中 PUID 和 PGID 的作用与配置指南
PUID 和 PGID 并非 Docker 原生设置,而是 linuxserver.io 镜像的约定。本文解释了为何绑定挂载文件会出现 911:911 用户权限问题,并教您通过 id 命令获取正确的 UID 和 GID,从而确保容器内外的文件所有权一致,避免权限冲突。
PUID 和 PGID 的实际含义
PUID 和 PGID 是某些容器镜像在启动时读取的两个环境变量。Docker 本身不会读取它们。这是一种约定,linuxserver.io 镜像和少数其他镜像会使用它。因此,未实现读取逻辑的镜像会静默忽略它们。
在 linuxserver.io 镜像中,有一个名为 abc 的用户。该用户在构建镜像时创建,UID(用户 ID)为 911,GID(组 ID)为 911。容器以 root 身份启动并运行初始化脚本。其中一个脚本会在应用启动前重新分配该用户的 ID:
groupmod -o -g "${PGID}" abc
usermod -o -u "${PUID}" abc-o 标志允许使用已在其他位置占用的 ID。随后,初始化过程会降权,并以 abc 身份运行应用。因此,Docker 像传递其他环境变量一样,将 PUID=1000 传入容器,但不会对其执行任何操作。该变量会在应用启动前重新分配容器内用户的 ID。这意味着,在未启用用户命名空间重映射的标准 rootful daemon 上,新创建的文件通常会以 1000 的所有者身份写入宿主机磁盘。写入已有的可写文件不会更改其所有者。Rootless Docker 会对这个数字进行偏移,具体说明见下文。将 PUID 留空时,abc 会保留 911,因此未配置的绑定挂载会产生由 911:911 所有的文件。
使用 id 获取这两个数字
在主机上以拥有数据目录的用户身份运行:
iduid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo),988(docker)uid 是您的 PUID,gid 是您的 PGID。在脚本中,id -u 和 id -g 会输出不带其他内容的数字。在大多数全新 VPS 镜像中,第一个普通用户的 UID:GID 是 1000:1000,但不要假定一定如此。分配结果取决于现有账户和配置:重建后的服务器可能再次分配 1000,而之后创建的账户会获得更高的可用 ID。如果服务以 专用服务账户而不是您自己的登录用户 身份运行,请执行 id thatuser,并从输出中获取这两个数字。
为何文件显示为 911:911
当没有主机账户与该 ID 匹配时,ls -l 会输出数字 ID,而不是名称。如果没有主机账户与 UID 911 匹配,就没有可输出的名称。使用 ls -ln 可始终显示数字,避免歧义:
ls -ln /srv/appdata/sonarrdrwxr-xr-x 2 911 911 4096 Aug 7 09:12 Backups
-rw-r--r-- 1 911 911 512 Aug 7 09:12 config.xml该输出与容器内置的默认值一致,但仅凭现有文件无法证明应用当前以哪个用户运行。该列表中的 config.xml 同时也是保存身份验证设置的文件。首次打开 Web UI 时,这一点很重要,因为你会发现 Sonarr 和 Radarr 发布时没有默认用户名或密码。不要猜测,直接在容器内确认:
docker exec sonarr id abc
docker compose logs sonarr | head -n 25linuxserver init 会在启动日志中以两行输出结果:
User UID: 911
User GID: 911如果你在 Compose 文件中设置 PUID=1000 后,这两行仍显示 911,说明运行中的容器没有使用你设置的值。通常的原因是你编辑了 docker-compose.yml,然后运行了 docker compose restart;该命令会复用现有容器及其原始环境。环境变量发生变化时,需要使用 docker compose up -d,以重新创建容器。另一种原因是预期行为:镜像以只读模式或非 root 模式运行时,init 会完全跳过重新编号,即使容器中存在该变量。在只读模式下,启动横幅会报告 abc 未改变的 ID;在非 root 模式下,会报告 /run 的所有者。
无法删除容器创建的文件
内核只比较数字,不比较名称。您的 shell 以 UID 1000 运行。该文件属于 UID 911。存放该文件的目录为 drwxr-xr-x,同样属于 UID 911,因此组用户和其他用户只有读和执行权限,没有写权限。删除文件需要对其所在目录具有写权限,而不是对文件本身具有写权限。因此,即使文件本身看起来没有问题,您仍会遇到以下错误:
rm: cannot remove '/srv/appdata/sonarr/config.xml': Permission denied写入容器从另一侧也会遇到同样的限制。如果主机目录属于您的用户,权限模式为 755,而应用以 911 身份运行,那么它的第一次写入就会因 Permission denied 失败,应用会用自己的措辞报告该错误。在 Sonarr 或 Radarr 这类 .NET 应用中,该错误会显示为 UnauthorizedAccessException: Access to the path '/data/downloads' is denied。文件前面的权限字符串会告诉您实际受到三组权限中的哪一组约束。正确读取 drwxr-xr-x后,这个错误就不再神秘,而是显而易见。
这具体是 bind mount 的问题。当 Docker 创建一个空的命名卷,并将其挂载到镜像中已存在的路径上时,会把该路径的内容复制到卷中,包括所有权和权限位。因此,应用会找到一个自己已经拥有的目录。bind mount 不会执行此处理:Docker 会完全按照原样挂载您的主机目录。这一差异也是您需要了解何时 bind mount 优于命名卷以及何时不是的实际原因之一。
修复已经错误的目录
设置 PUID 和 PGID 只会改变应用之后的运行方式。它不会追溯修复磁盘上已经存在的所有文件,但初始化过程会修正下文所述特定路径的所有权。停止服务栈,手动修正所有权,然后再次启动:
docker compose down
sudo chown -R 1000:1000 /srv/appdata/sonarr
docker compose up -d如果不想手动输入数字,可以使用 sudo chown -R "$(id -u):$(id -g)" /srv/appdata/sonarr。请在容器停止后执行此操作,因为应用正在运行并写入文件时执行递归 chown,可能导致目录树只完成部分修正,并引发难以判断原因的第二轮错误。
PUID 和 PGID 无法修复的问题
下面是最容易让已经正确完成配置的用户遇到问题的部分。linuxserver 基础镜像的用户设置脚本会在启动时对 3 个路径执行 chown:/app、/config 和 /defaults。您的媒体挂载路径不在此列表中。/data、/downloads 和 /tv 会原样交给应用处理。因此,如果这些挂载在主机端的所有者或权限不允许容器用户写入,容器仍会正常启动,在启动信息中显示正确的 UID,但会在第一次导入时失败。
这正是正确的行为。每次容器启动时,都对一个 12 TB 的媒体库递归执行 chown,会造成严重问题。但这也意味着媒体目录需要由您负责维护;权限问题实际发生的正是这些挂载路径。此类故障通常不会立即显现,而是在容器看似健康数小时后,才静默记录到应用日志中。将定期写入测试接入您自己的 VPS 上的 ntfy,并将告警推送到手机,可以低成本地提前发现问题,而不必等到一周没有新剧集后才注意到。
控制用户的三种方式,以及各自的适用场景
PUID 和 PGID 环境变量
这只适用于入口点会读取这些变量的镜像。这种方式很常见,因为容器仍会以 root 启动,完成自身初始化,修复 /config,然后再降权运行。Docker Mods 和自定义初始化脚本仍可正常工作。代价是依赖约定,而不是平台功能,并且不同项目使用的变量名并不统一。
Compose 中的 user: 键
这是 Docker 的原生功能,适用于所有镜像,因为容器运行时会在镜像自身代码运行前应用它:
services:
sonarr:
image: lscr.io/linuxserver/sonarr:latest
user: "1000:1000"进程从不以 root 运行,哪怕只有一瞬间,因此确实提高了安全性。但入口点中任何需要 root 的操作都会失效。在 linuxserver 镜像中,项目仅在合理努力的基础上提供此功能,而且只支持经过测试的镜像。具体限制如下:PUID 和 PGID 不再生效,Docker Mods 不会运行,自定义服务不会运行,并且您需要负责每个挂载卷的权限。其文档中的模式会将该标志与可写的 /run 配合使用:
user: 1000:1000
tmpfs:
- /run:uid=1000,gid=1000,exec
security_opt:
- no-new-privileges=true还有一个容易让人意外的外观问题。容器的 /etc/passwd 中没有对应条目的数字 user:,会导致容器内的工具报告 whoami: cannot find name for user ID 1000。该 ID 有效,文件访问也正常。只有名称解析会失败。
Rootless Docker
Rootless Docker 让 daemon 本身以您的非特权用户运行,因此 daemon 和容器都不会获得主机 root 权限。它会完全改变所有权的计算方式。容器 UID 0 会映射到运行 Rootless Docker 的主机用户 UID;对于值为 1 或更大的任意 n,容器 UID n 会映射到 subuid + (n - 1),其中 subuid 是在 /etc/subuid 中分配给您的 ID 范围起始值;组 ID 使用 /etc/subgid 遵循相同规则。Docker 要求其中至少有 65,536 个从属 ID。
请重新阅读这个映射关系,因为它与通常的建议相反。在 Rootless Docker 下,容器以 root 创建文件时,通常会生成一个归您所有的文件。容器以 UID 1000 创建文件时,通常会生成一个归从属 ID 所有的文件;例如,范围从 100000 开始时,所有者可能是 100999。您的 shell 是否可以访问该文件取决于权限和 ACL;删除文件通常取决于其所在目录,因此您可以从自己有控制权的目录中删除该文件。因此,在 rootful daemon 上正确的 PUID 值,在这里反而是错误的。两种机制在不同层面改变所有权;未经检查就叠加使用,可能导致您无法写入挂载的数据。如果使用 Rootless Docker,请先在自己的服务器上测试一个已写入文件的所有权,再将媒体库迁移到其中。
对于单个 VPS 上的大多数自托管服务栈,在 rootful daemon 上使用 PUID 和 PGID 是务实的选择,因为镜像就是按这种方式构建和记录的。只有在镜像 README 明确说明该镜像经过 user: 测试,或者您运行的是完全不支持 PUID 的官方上游镜像时,才应选择 user:。例如 在一台 VPS 上自托管 AFFiNE 实例的文档工作区 就属于后一种情况,因为其中没有任何容器读取 PUID,其数据库目录和上传文件的所有权由运行时决定,而不是由环境变量块中的设置决定。自托管 Chatwoot 工单支持台 也是如此:Rails 容器及其 Sidekiq worker 都会写入同一个上传目录,而且二者都不读取 PUID,因此该目录必须匹配镜像已经使用的用户。对于更新的服务栈,情况也不会改变。因此,为团队中的每个人提供独立沙箱化的 OneCLI agent 时,每个人的工作区目录和 Postgres 数据目录仍由各镜像已经使用的用户拥有;这属于 user: 和 chown 问题,而不是 PUID 问题。在 Codex、Claude Code 和 Hermes 前面放置 一个自托管 API 后,其入口点会管理多个身份:容器必须以 root 启动,控制台和网关以非特权用户运行,每个 agent 会话则以各自的用户运行。不要向该部署添加 user:;其入口点会处理数据卷的所有权。
媒体栈案例:容器之间共享一个组
包含 Sonarr、Radarr 和下载客户端的 arr 媒体栈 是这一机制从理论变为实际的场景。下载客户端将已完成的文件写入 /data/downloads。Sonarr 随后将该文件创建硬链接或移动到 /data/media。要使硬链接正常工作,两个容器必须对同一目录树具有写入权限。如果下载客户端以 1000 运行,而 Sonarr 以 1001 运行,且没有共享的组写入权限,那么其中一个容器创建的文件将归其所有,另一个容器只能读取。
解决方法是创建一个共享组,让栈中的每个容器都将其 PGID 设为该组的 GID:
sudo groupadd -g 13000 media
sudo usermod -aG media deploy
sudo chown -R deploy:media /srv/media
sudo find /srv/media -type d -exec chmod 2775 {} +
sudo find /srv/media -type f -exec chmod 0664 {} +2775 开头的 2 是 setgid 位。对于目录,这表示在其中创建的每个新文件和子目录都会继承组 media,而不是创建者自己的主组。因此,添加新下载后无需重新运行 chown,此配置仍然有效。检查自己的访问权限前,请注销并重新登录,或运行 newgrp media:使用 usermod -aG 添加的组不会出现在已经打开的 shell 会话中。
在容器内,groupmod -o -g 13000 abc 会将 abc 组重新编号为 13000,使 abc 以与你的主机 media 组相同的 GID 写入。所有访问此目录树的 linuxserver 容器都保留各自的 PUID,并共享同一个 PGID。这也包括链路中后续只读取已完成媒体库的容器,例如 Jellyfin 本身。将该媒体库作为可浏览的 90 年代视频商店提供服务的 Halcyon 等前端通过 HTTP 使用 HALCYON_JELLYFIN_URL 连接到 Jellyfin;其标准部署既不需要共享媒体 PGID,也不需要挂载媒体目录。
然后在栈中的每个 linuxserver 容器上设置 UMASK=002。这是最容易遗漏的一步。如果不设置该变量,除非其他配置改变了 umask,应用就会继续使用继承的 umask;镜像 README 中展示的示例值是 UMASK=022。当有效 umask 为 022 时,如果没有默认 ACL,应用请求为新文件使用 0666,实际得到的是 0644:组中的其他成员仍可读取,但无法修改,因此你刚配置的大部分共享权限会失效。使用 002 且没有默认 ACL 时,应用为文件请求通常的 0666、为目录请求 0777,则会得到 0664 文件;在 setgid 目录树中,还会得到 2775 目录,并且组具有写入权限。umask 只会移除权限位,因此应用请求 0600 时仍会得到 0600;而携带默认 ACL 的目录会完全忽略 umask。将该变量添加到 PUID 和 PGID 旁边:
services:
sonarr:
image: lscr.io/linuxserver/sonarr:latest
container_name: sonarr
environment:
- PUID=${PUID}
- PGID=${PGID}
- UMASK=002
- TZ=Etc/UTC
volumes:
- /srv/appdata/sonarr:/config
- /srv/media:/data
restart: unless-stopped这两个值应放在 Compose 文件旁边的 .env 文件中,使整个栈读取同一个定义:
PUID=1000
PGID=13000Compose 会自动读取该文件,用于 ${PUID} 样式的替换;凭据也使用同一种机制。不要将值写入 docker-compose.yml,而是放入 .env 文件的做法同样适用于此处。区别在于,这两个数字不是机密。
不要只信任配置,而应端到端验证。以应用用户身份创建一个新文件,并应用已配置的 umask,然后从主机读取该文件。使用一个尚不存在的测试文件名:
docker exec --user abc sonarr sh -c 'umask "$UMASK" && touch /data/downloads/permtest'
ls -ln /srv/media/downloads/permtest正常结果应显示你的 PUID 为所有者,13000 为组,-rw-rw-r-- 为权限模式。如果组显示为 1000,请运行 ls -ldn /srv/media/downloads:要么该目录缺少 setgid 位(即 drwxrwsr-x 中的 s),要么目录本身仍属于组 1000,而不是 media;上面的 chown 和 chmod 2775 命令可以修复这两种情况。如果权限模式显示为 -rw-r--r--,请检查容器的 UMASK 值和目录的默认 ACL;如果容器中的环境变量已过期,请重新创建容器。此测试会在配置的掩码下以 abc 身份创建文件;应用自身的创建模式和有效 umask 仍会决定其文件的权限。完成后,使用 rm /srv/media/downloads/permtest 删除测试文件。
哪些镜像使用哪些变量
linuxserver.io 镜像使用 PUID、PGID 和 UMASK。Paperless-ngx 对同一机制使用不同名称:USERMAP_UID 和 USERMAP_GID,默认值均为 1000;其文档要求您从 id -u 和 id -g 中读取这些值。照片服务器也存在相同差异:PhotoPrism 使用自己的 PHOTOPRISM_UID 和 PHOTOPRISM_GID,而 Immich 不提供等效变量,而是让容器用户使用 Docker 的 user: 键。因此,在 PhotoPrism 和 Immich 之间进行选择,也意味着您要为这台服务器上最大的媒体库维护不同的用户身份机制。许多官方上游镜像,包括常见的数据库和 Web 服务器镜像,都使用固定的内置用户,并要求您使用 user:,或保持默认设置。小型单应用部署也有相同问题。因此,在部署 自托管的 openGym 训练跟踪器 时,应先确认其容器实际以哪个用户运行,再为它配置绑定挂载。无论是否设置 PUID,存放其数据库的目录都会继承这一用户身份。远程访问中继也属于同一类情况。因此,在 运行您自己的 RustDesk 中继服务器 时,hbbs 首次启动时写入的 Ed25519 密钥对会以该镜像最终使用的用户身份出现在绑定挂载目录中。之后,您可以在主机上使用 chown 修正所有权,或通过 user: 键预先选择运行身份。该镜像不会自行设置用户,但前提是挂载目录对您选择的 ID 可写。以后添加其他基础设施时也适用同一原则。因此,将 Authentik 放在您的应用前面以实现统一登录,意味着运行官方 authentik 和 Postgres 镜像。这些镜像完全不读取 PUID,卷的所有权取决于其进程运行时使用的用户,以及入口点执行的初始化设置。Authentik 在 2025.10 版本中移除了对 Redis 的依赖。
因此,在跨项目复制环境变量配置块之前,应先查看每个镜像的 README。Docker 会将您设置的任何环境变量传入任何容器,无论容器内是否有程序读取该变量。如果没有任何程序使用 PUID,则不会产生错误、警告或任何效果。容器会以 Docker 的 user: 设置或 Dockerfile 选定的用户启动,随后其入口点可以在启动应用前降权运行。添加任何新服务前都应先确认这些信息,包括 自托管的 open-kritt 安全扫描栈。Compose 文件会说明这些镜像是否支持 PUID,或者您挂载目录的所有权是否由镜像本身固定。
FAQ
我的 Docker 文件为什么归 911:911 所有?
911 是 linuxserver.io 镜像内置的 abc 用户的 UID 和 GID。它与内置默认值一致,但在更改容器的 PUID 和 PGID 后,文件仍可能保留该所有权。主机上没有账户与这些 ID 匹配时,ls -l 会显示原始数字,因此不会显示名称。将 PUID 和 PGID 设置为 id 的输出,使用 docker compose up -d 重新创建容器,然后对受影响的目录执行 sudo chown -R 1000:1000,修复现有文件的所有权。
PUID 和 PGID 适用于每个 Docker 镜像吗?
不适用。它们不是 Docker 功能,Docker 也不会读取它们。只有镜像自身的 entrypoint 会读取这些变量,并在启动应用前调用 usermod 和 groupmod 时,它们才会生效。这类镜像包括 linuxserver.io 系列,以及少数采用了相同模式的项目。其他项目使用不同的变量名,例如 paperless-ngx 中的 USERMAP_UID 和 USERMAP_GID。对于既不读取这两类变量的镜像,变量会被接受并静默忽略。
在 Docker Compose 中,我应该使用 PUID 和 PGID,还是 user: 密钥?
镜像支持时,使用 PUID 和 PGID,因为 entrypoint 仍会以 root 身份运行足够长的时间,以修复 /config 并正确启动自身的服务。如果镜像文档说明支持所选用户,则使用 user:;仅不支持 PUID,并不表示其 entrypoint 可以在没有 root 的情况下运行。在 linuxserver 镜像中,设置 user: 会使 PUID 和 PGID 失效,阻止 Docker Mods 和自定义服务运行,并让您负责所有挂载卷的权限。
Sonarr 的 PUID 正确,但仍无法移动文件。问题在哪里?
按以下顺序检查三项。第一项是媒体挂载本身:基础镜像的用户设置脚本会对 /app、/config 和 /defaults 执行 chown,因此 /data 或 /downloads 会保留它在主机上的现有所有权。第二项是共享组:为访问媒体目录树的容器设置相同的 PGID,并授予组写权限。第三项是 umask:当应用请求 0666 且没有默认 ACL 时,有效掩码 022 会生成 0644 文件,因此组中的其他容器可以读取这些文件,但不能修改。未设置 UMASK 不保证使用特定的掩码或文件模式。设置 UMASK=002,并使用 chmod 2775 为目录设置 setgid 位,使新文件继承该组。即使应用请求 0600,仍会创建仅所有者可访问的文件;默认 ACL 会改变创建权限的分配方式。