背景
项目中有算法服务和后端服务,后端服务负责文件上传与保存,算法服务负责文件内容解析、报告生成与保存;采用docker-compose方式部署,
两个服务都会读写同一目录/shared(挂载自宿主机/data/project/share-data)。
上线后发现:后端服务无法成功上传文件,算法服务无法将生成的报告写入目录,服务日志提示权限不足的错误,本文记录排查过程,并给出共享组+SGID+umask的解决方案。
正文
问题现象与环境
权限不足的错误
PermissionError:[Errno13]Permission denied:'/shared/uploads'docker-compose.yml 挂载配置
# ---- 后端服务 ----backend-app: volumes: - /data/project/share-data:/shared# ---- 算法服务 ----ai-app: volumes: - /data/project/share-data:/sharedDockerfile
# ---- 算法服务 ----# 创建了 UID 为 10001、名为 appuser 的用户RUNuseradd...--uid10001appuser# 指定后续指令及容器运行时以此用户身份执行USERappuser# ---- 后端服务 ----# 创建了 appuser 的用户和用户组(useradd -r appuser 的 uid是系统分配的,建议使用固定 uid,防止镜像重建时 uid 变更)RUNgroupadd-rappuser&&useradd-r-gappuser-d/app-s/sbin/nologin appuser# 指定后续指令及容器运行时以此用户身份执行USERappuser排查过程
本着 “尽量改配置、不动两个服务 Dockerfile” 的原则,开始了排查。以下内容均排除 root 场景。
第 1 步:查看宿主机的共享目录的权限。
$ls-l/data/project/ drwxrwxr-x2ubuntu ubuntu40969月1416:21 share-data $idubuntuuid=1000(ubuntu)gid=1000(ubuntu)目录的属主和属组都是 ubuntu, 权限 775 (属主 rwx,属组 rwx,其他人 r-x)
第 2 步:对比两个容器内的用户身份。
# 后端服务容器内$iduid=999(appuser)gid=999(appuser)groups=999(appuser)# 算法服务容器内$iduid=10001(appuser)gid=10001(appuser)groups=10001(appuser)# 宿主机确认目录属主属组的数字 id$idubuntuuid=1000(ubuntu)gid=1000(ubuntu)两个容器内的进程 UID 不同,且都不是 root,也不属于 共享目录的属组gid(1000)。
第 3 步:定位根因。
两个容器里的用户 id 都不等于 共享目录的属主 uid(1000) ,也不属于共享目录的属组 gid(1000),作为共享目录的其他用户,其他用户没有 写(w)的权限,而在目录里新建文件 需要目录的 w+x 权限,两个容器都不满足权限需求,所以提示Permission denied。
根因分析: Linux 是怎么判断文件或目录的读写权限的?
前提:先通过路径解析
访问一个文件,内核先逐级解析路径,每一级目录都需要 x 权限:
访问/data/project/share-data/a.txt ├─/需要 x ├─/data需要 x ├─/project 需要 x ├─/share-data需要 x └─/a.txt 需要看文件权限任何一级目录缺 x,路径解析失败,直接拒绝。
第 1 步:判断进程的 UID 是否是 root
if(进程UID==0){// root 跳过权限检查(除少数特殊情况,如执行位;实际情况还受其他条件的影响) 允许}注意: root 对文件读写通常无条件允许,但对执行仍需至少一个 x 位。
第 2 步:比较属主权限位(属主位:只有文件属主本人能用)
if(进程有效UID==文件属主UID){使用文件的“属主权限位”# 如果属主权限位不满足读写条件,则拒绝跳到第5步}第 3 步:比较属组权限位 (属组位:文件属组的所有成员都能用)
if(进程组列表(主组 + 附加组)包含 文件属组 GID){使用文件的“属组权限位”# 如果属组权限位不满足读写条件,则拒绝跳到第5步}组位是 rw- 还是 r–,决定能不能写
关键:组列表是主组 + 附加组的并集,只要包含文件属组 GID 就算匹配。
第 4 步:比较其他人权限位
否则{使用文件的“其他人权限位”# 如果其他权限位不满足读写条件,则拒绝}第 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 + 组可写)方案落地
宿主机侧:
# 0. 关键:目录必须先建好再启动容器,否则 Docker 会建一个 root:root 的目录(当前情况下已有该目录)sudomkdir-p/data/project/share-data# 1. 建共享组,固定 GIDgroupadd-g1001fileshare# 2. 共享目录属组改为 1001chown1000:fileshare /data/project/share-data# 1000 可替换为当前登录的系统用户的id# 3. 【易漏,容易踩坑点】历史文件和目录属组一并迁移:SGID 只对"新建"文件生效chgrp-Rfileshare /data/project/share-data# 历史文件补组写位(保留 x 和其他位):umask只对"新建"文件生效find/data/project/share-data-typef-execchmodg+w{}+# 历史子目录补「组写 + SGID 位」:find/data/project/share-data-typed-execchmodg+w,g+s{}+# 4. 目录设 SGID + 组的读写执行权限 rwx: 2775 = 2(setgid) + 775chmod2775/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:
# 后端服务加入共享组,(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 的配置选其一即可:
umask0002# 必须在 exec 之前设置exec"$@"# exec 保持信号传递与 PID 1 语义umask 0002 的作用:新文件默认权限666 & ~002 = 664,保留组写位;新目录777 & ~002 = 775。
结果验证
目录: drwxrwsr-x10001001/data/project/share-data ,属组权限 rws 出现了 s说明 sgid生效 文件: -rw-rw-r--9991001a.txt ← 后端服务(uid999)建的文件 文件: -rw-rw-r--100011001b.txt ← 算法服务(uid10001)建的文件在两个容器的 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 会失败。
$sudochmod444/data/demo/test $ls-altestls: 无法访问'test/1.txt':权限不够 ls: 无法访问'test/2.txt':权限不够 ls: 无法访问'test/..':权限不够 ls: 无法访问'test/.':权限不够 总计0d????????? ? ? ? ? ?.d????????? ? ? ? ? ?..-????????? ? ? ? ? ?1.txt -????????? ? ? ? ? ?2.txt $cattest/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,容器里的属组靠识别数字匹配生效。
排错速查清单
# 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|grepshare-data总结
本次的排查过程也是逐渐发现和学习的过程,当前本次的解决方案也是建立在一些前提下的,如果 docker engine 开启了 userns-remap,或者目录位于 NFS 或者设置了只读挂载,本次共享组的方案是不生效的,本文中的一些内容未详细说明,包括linux的权限模型、ACL设置、方案不生效的原因等还需要探究,下篇博客将为您继续解答,敬请期待!