背景
项目中有算法服务和后端服务,后端服务负责文件上传与保存,算法服务负责文件内容解析、报告生成与保存;采用docker-compose方式部署,
两个服务都会读写同一目录 /shared(挂载自宿主机 /data/project/share-data)。
上线后发现:后端服务无法成功上传文件,算法服务无法将生成的报告写入目录,服务日志提示权限不足的错误,本文记录排查过程,并给出共享组+SGID+umask的解决方案。
正文
问题现象与环境
权限不足的错误
bash
PermissionError: [Errno 13] Permission denied: '/shared/uploads'
docker-compose.yml 挂载配置
bash
# ---- 后端服务 ----
backend-app:
volumes:
- /data/project/share-data:/shared
# ---- 算法服务 ----
ai-app:
volumes:
- /data/project/share-data:/shared
Dockerfile
bash
# ---- 算法服务 ----
# 创建了 UID 为 10001、名为 appuser 的用户
RUN useradd ... --uid 10001 appuser
# 指定后续指令及容器运行时以此用户身份执行
USER appuser
# ---- 后端服务 ----
# 创建了 appuser 的用户和用户组(useradd -r appuser 的 uid是系统分配的,建议使用固定 uid,防止镜像重建时 uid 变更)
RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser
# 指定后续指令及容器运行时以此用户身份执行
USER appuser
排查过程
本着 "尽量改配置、不动两个服务 Dockerfile" 的原则,开始了排查。以下内容均排除 root 场景。
第 1 步:查看宿主机的共享目录的权限。
bash
$ ls -l /data/project/
drwxrwxr-x 2 ubuntu ubuntu 4096 9月 14 16:21 share-data
$ id ubuntu
uid=1000(ubuntu) gid=1000(ubuntu)
目录的属主和属组都是 ubuntu, 权限 775 (属主 rwx,属组 rwx,其他人 r-x)
第 2 步:对比两个容器内的用户身份。
bash
# 后端服务容器内
$ id
uid=999(appuser) gid=999(appuser) groups=999(appuser)
# 算法服务容器内
$ id
uid=10001(appuser) gid=10001(appuser) groups=10001(appuser)
# 宿主机确认目录属主属组的数字 id
$ id ubuntu
uid=1000(ubuntu) gid=1000(ubuntu)
两个容器内的进程 UID 不同,且都不是 root,也不属于 共享目录的属组gid(1000)。
第 3 步:定位根因。
两个容器里的用户 id 都不等于 共享目录的属主 uid(1000) ,也不属于共享目录的属组 gid(1000),作为共享目录的其他用户,其他用户没有 写(w)的权限,而在目录里新建文件 需要目录的 w+x 权限,两个容器都不满足权限需求,所以提示Permission denied。
根因分析: Linux 是怎么判断文件或目录的读写权限的?
前提:先通过路径解析
访问一个文件,内核先逐级解析路径,每一级目录都需要 x 权限:
powershell
访问 /data/project/share-data/a.txt
├─ / 需要 x
├─ /data 需要 x
├─ /project 需要 x
├─ /share-data 需要 x
└─ /a.txt 需要看文件权限
任何一级目录缺 x,路径解析失败,直接拒绝。
第 1 步:判断进程的 UID 是否是 root
bash
if (进程 UID == 0) {
// root 跳过权限检查(除少数特殊情况,如执行位;实际情况还受其他条件的影响)
允许
}
注意: root 对文件读写通常无条件允许,但对执行仍需至少一个 x 位。
第 2 步:比较属主权限位(属主位:只有文件属主本人能用)
bash
if (进程有效 UID == 文件属主 UID) {
使用文件的"属主权限位" # 如果属主权限位不满足读写条件,则拒绝
跳到第 5 步
}
第 3 步:比较属组权限位 (属组位:文件属组的所有成员都能用)
bash
if (进程组列表(主组 + 附加组)包含 文件属组 GID) {
使用文件的"属组权限位" # 如果属组权限位不满足读写条件,则拒绝
跳到第 5 步
}
组位是 rw- 还是 r--,决定能不能写
关键:组列表是主组 + 附加组的并集,只要包含文件属组 GID 就算匹配。
第 4 步:比较其他人权限位
bash
否则 {
使用文件的"其他人权限位" # 如果其他权限位不满足读写条件,则拒绝
}
第 5 步:根据操作类型判断权限位
拿到对应的权限位(属主/属组/其他人之一)后,看具体操作:
| 操作 | 需要的权限位 |
|---|---|
| 读文件内容(cat、read ) | r |
| 写文件内容(echo >>、write) | w |
| 执行文件(./run) | x |
| 列出目录(ls) | 目录的 r |
| 进入目录(cd) | 目录的 x |
| 在目录里建/删/重命名 | 目录的 w + x |
只要需要的位在对应的权限段里有,就允许;否则拒绝。
注意:linux内核在容器内外不看用户名,只看 uid/gid 数字。999 ≠ 10001 ≠ 1000。
方案评估与选择
| 方案 | 做法 | 优点 | 缺点 | 结论 |
|---|---|---|---|---|
| 1.统一 uid / gid | 两个容器用相同 uid/gid 运行,对齐目录属主/属组;或修改目录属主/属组与容器 uid/gid 对齐 | 简单直接 | 两个容器服务都要改 Dockerfile / docker-compose / 启动参数,还可能影响容器内其他文件权限 | 放弃 |
| 2.共享组(SGID + 组可写) | 建共享组,目录属组设为共享组,容器进程加入附加组,并设置 SGID 与组可写权限 | 不动 Dockerfile,只加配置 | 需要调整 docker-compose 启动配置(group_add、umask) | 采用 |
| 3.ACL | setfacl 分别给 999、10001 授权 |
只针对 uid 授权,无需改属组和 umask | 容器 uid 变化时需同步调整 ACL 命令 | 备选 |
| 4.放开 other 写权限 | chmod o+w 目录,或 chmod -R 777 /data/project/share-data |
只改一句,其他都不用动 | a. 安全风险较大;b. 新文件的 other 位仍受 umask 限制为 r--,单独用解决不了互写 |
放弃 |
| 5.容器以 root 运行 | 两个服务都用 root 用户运行 | 简单省事 | 安全风险极大 | 放弃 |
综合评估,选择了方案二,只加配置不动镜像
共享组(SGID + 组可写)方案落地
宿主机侧:
bash
# 0. 关键:目录必须先建好再启动容器,否则 Docker 会建一个 root:root 的目录(当前情况下已有该目录)
sudo mkdir -p /data/project/share-data
# 1. 建共享组,固定 GID
groupadd -g 1001 fileshare
# 2. 共享目录属组改为 1001
chown 1000:fileshare /data/project/share-data # 1000 可替换为当前登录的系统用户的id
# 3. 【易漏,容易踩坑点】历史文件和目录属组一并迁移:SGID 只对"新建"文件生效
chgrp -R fileshare /data/project/share-data
# 历史文件补组写位(保留 x 和其他位):umask只对"新建"文件生效
find /data/project/share-data -type f -exec chmod g+w {} +
# 历史子目录补「组写 + SGID 位」:
find /data/project/share-data -type d -exec chmod g+w,g+s {} +
# 4. 目录设 SGID + 组的读写执行权限 rwx: 2775 = 2(setgid) + 775
chmod 2775 /data/project/share-data
# 5. 宿主机侧用户放文件时也要保留组写位(umask 022 会导致新文件为 644)(看具体情况设置)
echo "umask 002" >> ~/.profile # 或每次放完后:sudo chmod -R g+w /data/project/share-data
# 6. 确认结果(-n 显示数字 uid/gid,避免容器内组名解析不出来)
ls -ldn /data/project/share-data # 期望:drwxrwsr-x 1000 1001
容器侧(docker-compose.yml) 调整 group_add 和 umask:
bash
# 后端服务加入共享组,(group-add 只增加进程的附加组身份,不会自动改变目录属组,也不会自动让新建文件拥有组写权限。)
backend-app:
group_add:
- "1001"
# 设置 umask 0002,使新建文件属组可写
command: sh -c "umask 0002 && exec python app.py"
# 算法服务加入共享组
ai-app:
group_add:
- "1001"
# 设置 umask 0002,使新建文件属组可写
command: sh -c "umask 0002 && exec python algorithm.py"
容器侧(entrypoint/command) 这里和上述 docker-compose command 的配置选其一即可:
bash
umask 0002 # 必须在 exec 之前设置
exec "$@" # exec 保持信号传递与 PID 1 语义
umask 0002 的作用:新文件默认权限 666 & ~002 = 664,保留组写位;新目录 777 & ~002 = 775。
结果验证
bash
目录: drwxrwsr-x 1000 1001 /data/project/share-data ,属组权限 rws 出现了 s说明 sgid生效
文件: -rw-rw-r-- 999 1001 a.txt ← 后端服务 (uid 999) 建的文件
文件: -rw-rw-r-- 10001 1001 b.txt ← 算法服务 (uid 10001)建的文件
在两个容器的 UID(文件所有者)无法统一的情况下,通过 共享组(gid)的方式,使两个容器进程拥有了公共的身份,设置SGID 目录让目录下的新文件继承目录的属组(说明:新建的子目录不仅继承属组,还会继承 SGID 位,所以共享目录的子层级自动继续生效;普通文件只继承属组,不继承 SGID 位。),设置 umask 让新文件组的权限位有了写的权限,以上共同作用两个容器才都能写入共享目录,操作其中的文件。
| 步骤 | 作用对象 | 机制 | 解决 | 缺失后果 |
|---|---|---|---|---|
| 共享组 | 进程(用户本质上是进程)+目录 | group_add + chgrp | 进程有 1001 组身份,目录属组 1001 | 组权限不生效 |
| 目录 rwx | 目录 | chmod 775 的组位 | 能在目录里建/删文件、进入目录 | 进不了目录或建不了文件 |
| 目录 SGID | 目录 | chmod g+s | 新文件属组 = 1001 | 新文件属组 = 创建者主组,对方写不了 |
| umask 002 | 进程 | umask 0002 | 新文件组位有 w | 新文件组位 r--,对方只能读 |
最容易踩坑的几个点
- 删除、新建、重命名文件看父目录权限
删除、新建、重命名文件需要父目录的 w+x 权限,不需要文件本身的权限,新建的文件的权限由基本权限和 umask 决定。 - SGID 继承组,不继承完整权限
- 目录设置 SGID(chmod g+s 或 2775)后,在该目录下新建的文件/子目录会继承该目录的所属组,而不是创建者的主组。但它不会继承目录的完整权限位。
- 目录里的文件权限还是由新建文件时的默认权限和 umask 综合设置的,组是组,权限是权限,权限是对文件或目录设置的。
- umask和 SGID 只影响新建对象
umask 和SGID只在创建文件/目录时参与计算默认权限,对已有对象没有任何作用,所以当前问题需要对已有的目录/文件批量补一次。 - 目录必须有 x 权限
- 目录的 x 位表示"能否进入/穿越该目录",是访问目录内任何对象的前提。所以目录新建时的默认权限保留 x,基准权限为 777,实际默认权限由 umask 决定。
- 只给 r 不给 x:ls dir 可能显示名字,但 ls -l dir、cat dir/file 会失败。
bash
$ sudo chmod 444 /data/demo/test
$ ls -al test
ls: 无法访问 'test/1.txt': 权限不够
ls: 无法访问 'test/2.txt': 权限不够
ls: 无法访问 'test/..': 权限不够
ls: 无法访问 'test/.': 权限不够
总计 0
d????????? ? ? ? ? ? .
d????????? ? ? ? ? ? ..
-????????? ? ? ? ? ? 1.txt
-????????? ? ? ? ? ? 2.txt
$ cat test/1.txt
cat: test/1.txt: 权限不够
- 文件和目录的 rwx 含义不同
| 位 | 对文件 | 对目录 |
|---|---|---|
| r | 读取文件内容 | 列出目录中的条目名 |
| w | 修改文件内容 | 在目录中创建/删除/重命名条目 |
| x | 执行文件 | 进入/穿越目录,访问目录内对象 |
- echo "hello" > /shared/new.txt 指令中权限如何判断?是文件还是目录的权限?
执行上述一条命令,写了个新文件,但实际分为两个独立动作,先创建文件,再写文件内容
动作 1:在目录 /shared 里创建一个新目录项 new.txt
→ 权限判断对象:目录 /shared,检查目录有 w+x 权限,才允许创建
动作 2:往 new.txt 这个文件里写入内容 "hello"
→ 权限判断对象:文件 new.txt,检查文件有 w权限,才允许写入
- 组的作用对象是谁?
- 作用对象:用户、目录或文件、进程 ;
- 通过为用户设置共享组、目录或文件设置共享组,容器内的进程加入共享组,才让容器里的用户或进程操作共享目录,共享的是 gid,容器里的属组靠识别数字匹配生效。
排错速查清单
bash
# 1. 进程到底是谁 ------ 容器内执行
id
# 2. 目录到底归谁 ------ 用 -n 看数字,容器内组名常常解析不出来
ls -ldn /data/project/share-data
# 3. 路径每一级的权限 ------ 逐级列出,定位"卡在哪一层"
namei -l /data/project/share-data/uploads/a.txt
# 4. 有没有 ACL 覆盖 ------ 传统 rwx 看着对,但可能被 ACL 拦下
getfacl /data/project/share-data
# 5. 挂载选项 ------ 只读挂载 / nosuid 会让一切配置失效
mount | grep share-data

总结
本次的排查过程也是逐渐发现和学习的过程,当前本次的解决方案也是建立在一些前提下的,如果 docker engine 开启了 userns-remap,或者目录位于 NFS 或者设置了只读挂载,本次共享组的方案是不生效的,本文中的一些内容未详细说明,包括linux的权限模型、ACL设置、方案不生效的原因等还需要探究,下篇博客将为您继续解答,敬请期待!