Docker 与容器排查 列表全部文章
Docker 与容器排查

挂载目录 Permission denied 及两个“难兄难弟”:时区差 8 小时、改了代码镜像纹丝不动

容器写挂载目录报 Permission denied,根因常是 UID 不一致,也可能是 SELinux 或只读挂载。顺带整理容器时区不对、构建缓存导致改了代码镜像没更新两个小坑,每条都给出复现现象与 id、ls -ln、docker exec 排查命令。

更多

这次不讲惊天动地的大事故,讲三个“小坑合集”。它们有个共同点:每一个都不至于让系统宕机,但每一个都能在你上线的凌晨让你怀疑人生。

第一个是主角:“挂载目录 Permission denied”。测试环境好好的,换了台服务器,容器一启动就报错。第二个是“日志时间差了 8 小时”,运营同学问我为什么昨天的订单“发生在今天早上 3 点”。第三个是“我明明改了代码,重新构建部署,怎么线上还是老逻辑”——我对着屏幕重复构建了四遍,直到旁边同事悄悄说:“杰哥,你 compose up 没带 --build。”

下面按“复现现象 → 排查命令 → 根因 → 修法”整理。三个坑全部可以用几条固定命令定位,写成备忘录,以后直接抄。

一、主线:挂载目录 Permission denied

1. 现象

新服务部署到生产机,日志目录用 bind mount 挂到宿主机:

$ docker run -d --name order-svc -v /data/order/logs:/app/logs demo/order-svc:1.0
$ docker logs --tail 5 order-svc
Caused by: java.io.FileNotFoundException: /app/logs/app.log (Permission denied)
    at java.base/java.io.FileOutputStream.open0(Native Method)
    at ch.qos.logback.core.recovery.ResilientFileOutputStream.<init>(ResilientFileOutputStream.java:30)
...
$ docker ps -a --format '{{.Names}} {{.Status}}'
order-svc Exited (1) 3 seconds ago

“(Permission denied)”——注意这个措辞,它区别于另外两个长得很像的错误:

  • No such file or directory:路径不存在;
  • Read-only file system:文件系统是只读挂载(后面讲)。

先别急着 chmod 777——那是最常见的“解决方案”,也是最常见的安全事故起点。我们先搞清楚谁在写、谁拥有目录。

2. 排查路线图

核心原理只有一句话:容器和宿主机共享同一个内核,也共享同一套数字形式的 UID/GID,二者之间没有任何自动映射(除非你启用了 userns-remap)。文件权限比较的是数字,不是用户名。

宿主机 /data/order/logs ls -ln → drwxr-xr-x 0 0 (属主 root, uid 0) 其他人(others)只有 r-x,没有 w 数字 uid = 0 容器(同一内核) Java 进程 (Dockerfile: USER app) id → uid=1001(app) gid=1001(app) 往 /app/logs 写文件 数字 uid = 1001,不是 0,也不在 root 组 write EACCES 四条修复路径(选一条) A. 宿主机目录 chown 成容器用户的 UID B. 容器以宿主目录属主运行:--user uid:gid C. 改用 named volume(首次挂载会继承镜像内路径的属主) D. 入口脚本以 root 修目录属主后降权运行 不要 chmod 777;SELinux 系统还要看 :z / :Z(见下文)

3. 动手:三条命令,拿到三个数字

命令 1:容器里的进程是谁?(容器起不来就用 docker run --rm --entrypoint id demo/order-svc:1.0)

$ docker run --rm --entrypoint id demo/order-svc:1.0
uid=1001(app) gid=1001(app) groups=1001(app)

命令 2:宿主机目录是谁的?务必用 -n,看数字而不是名字——名字来自各自的 /etc/passwd,宿主机的 app 用户和容器里的 app 用户完全可能不是同一个人:

$ ls -ln /data/order/
total 4
drwxr-xr-x 2 0 0 4096 Sep 25 10:12 logs
$ stat -c '%u:%g %a %n' /data/order/logs
0:0 755 /data/order/logs

属主 0:0(root),权限 755:属主可读写,其他人只有读和进入。容器里 UID 1001 属于“其他人”,所以不能在里面创建文件。1001 ≠ 0,就是根因。

命令 3:这个目录到底是谁创建的?为什么是 root?这里是一个经典细节:用 -v /data/order/logs:/app/logs 时,如果宿主机路径不存在,Docker 会自动帮你创建,属主是 root。(用 --mount type=bind 则会直接报错,不会偷偷创建。)所以“没人建过目录”恰恰是 root 拥有它的原因。

如果容器已经在运行,用 docker exec 进去实测比推理靠谱:

$ docker exec -u 1001 order-svc sh -c 'id; touch /app/logs/t.log; ls -ln /app/logs'
uid=1001 gid=1001
touch: cannot touch '/app/logs/t.log': Permission denied
total 0
$ docker inspect -f '{{json .Mounts}}' order-svc | python3 -m json.tool
[
    {
        "Type": "bind",
        "Source": "/data/order/logs",
        "Destination": "/app/logs",
        "Mode": "",
        "RW": true,
        "Propagation": "rprivate"
    }
]

"Type": "bind" 确认是 bind mount;"RW": true 说明不是只读挂载导致——那就是纯权限问题。

4. bind mount 与 named volume 的区别

为什么开发机上没问题?因为开发机的目录属主往往是你自己(UID 1000),而很多镜像里默认用户的 UID 也恰好是 1000,碰巧对上了。换到生产就现原形。两种挂载方式的区别,和这个坑直接相关:

对比项bind mountnamed volume
数据位置宿主机上你指定的任意路径Docker 管理,位于 /var/lib/docker/volumes/<name>/_data
属主/权限保持宿主机文件原样,容器必须“配合”首次挂载到非空的镜像路径时,会把镜像里该路径的内容连同属主、权限一起复制进卷(仅空卷首次)
路径不存在时-v 会自动以 root 创建;--mount 报错自动创建卷
适用场景配置文件注入、开发时代码热更新、需要宿主直接访问的日志目录数据库数据目录、应用持久化数据
迁移与备份直接拷目录需 docker volume / 挂临时容器 tar 备份

named volume 之所以“少踩坑”,是因为官方文档明确的这条行为:把一个空卷挂到容器中已有文件的目录,这些文件默认会被复制进卷——只要你在 Dockerfile 里事先 RUN mkdir -p /app/logs && chown app:app /app/logs,卷第一次挂载后就继承这个属主。但要注意“仅首次”:之后你改了镜像里的 UID,卷里的旧属主不会自动更新,还得手动 chown 或重建卷。

5. 修复:四选一(按我的偏好排序)

方案 A(生产首选):把宿主机目录属主改成容器用户的 UID。

$ sudo chown -R 1001:1001 /data/order/logs
$ ls -ln /data/order/
drwxr-xr-x 2 1001 1001 4096 Sep 25 10:12 logs
$ docker restart order-svc && docker logs --tail 3 order-svc
... Started OrderApplication in 6.8 seconds

宿主机上不一定有 UID 1001 的用户,没关系——chown 1001:1001 直接写数字即可,权限比较只看数字。建议在部署脚本里显式创建目录并设置属主,避免依赖“Docker 自动帮我创建”。

方案 B:让容器以指定 UID 运行。适用于目录属主不方便改的场景,比如开发者本机:

$ docker run -d --user "$(id -u):$(id -g)" -v "$PWD/logs:/app/logs" demo/order-svc:1.0
# docker-compose.yml
services:
  order-svc:
    image: demo/order-svc:1.0
    user: "1001:1001"
    volumes:
      - /data/order/logs:/app/logs

官方文档里 -u 支持 uid:gid 形式,数字 UID 范围是 0 到 2147483647,用户名则必须在容器里存在。注意:用 --user 覆盖后,进程可能没有对应的 /etc/passwd 条目,部分程序(如 Git、某些 Java 库读取 user.home)会有意外行为。

方案 C:改用 named volume,并在镜像里固化属主:

FROM eclipse-temurin:17-jre
RUN groupadd -g 1001 app && useradd -u 1001 -g 1001 -m app \
 && mkdir -p /app/logs && chown -R app:app /app
COPY --chown=app:app target/order-svc.jar /app/order-svc.jar
USER app
ENTRYPOINT ["java", "-jar", "/app/order-svc.jar"]
$ docker run -d --name order-svc -v order-logs:/app/logs demo/order-svc:1.0
$ docker volume inspect order-logs --format '{{.Mountpoint}}'
/var/lib/docker/volumes/order-logs/_data
$ sudo ls -ln /var/lib/docker/volumes/order-logs/_data/..
drwxr-xr-x 2 1001 1001 4096 Sep 25 10:20 _data

方案 D:入口脚本修属主再降权(容器需先以 root 启动,用 gosu/su-exec 降权),官方镜像(如 postgres、redis)普遍这么做,适合无法预知宿主机 UID 的通用镜像。缺点是启动时多一次 chown -R,目录很大时较慢。

不推荐:chmod -R 777。它能“解决”问题,同时让宿主机上任何用户、任何容器都能写这个目录,安全和审计都变得没法说。

6. 分支:UID 对上了还是 Permission denied?看 SELinux

如果 id 和 ls -ln 的数字明明一致(甚至容器是 root),仍然报 Permission denied,尤其是在 CentOS / RHEL / Fedora 这类默认启用 SELinux 的系统上,就该怀疑它了:

$ getenforce
Enforcing
$ ls -Z /data/order/
drwxr-xr-x. 1001 1001 unconfined_u:object_r:default_t:s0 logs
$ sudo ausearch -m avc -ts recent | tail -3
type=AVC msg=audit(1727257912.331:1187): avc:  denied  { write } for  pid=28812 comm="java" name="logs" dev="vda1" ino=524312 scontext=system_u:system_r:container_t:s0:c123,c456 tcontext=unconfined_u:object_r:default_t:s0 tclass=dir permissive=0

关键证据:scontext=...container_t(容器进程的 SELinux 域)想写 tcontext=...default_t(默认标签的宿主目录),被 AVC denied。SELinux 是在传统 UID 权限之外的另一道门,UID 对了它也可以拒绝你。

Docker 提供的办法是挂载时加标签选项(官方文档:只能用于 -v/--volume,不能用于 --mount):

  • :z:把宿主目录重打标签为“共享”,多个容器都可访问;
  • :Z:重打标签为“私有”,仅当前容器可访问(其他容器再挂就不行了)。
$ docker run -d -v /data/order/logs:/app/logs:z demo/order-svc:1.0
$ ls -Z /data/order/
drwxr-xr-x. 1001 1001 system_u:object_r:container_file_t:s0 logs
services:
  order-svc:
    volumes:
      - /data/order/logs:/app/logs:z

官方文档特别警告:不要对 /home、/usr 这类系统目录使用 :Z,会给它们重新打标签,可能让宿主机无法正常工作。这是那种“敲完命令,SSH 就再也连不上”的危险操作。一般共享目录用 :z,且只对专门的数据目录使用。不要为了图省事 setenforce 0 关掉 SELinux,那是把防盗门拆了。

7. 分支:只读挂载 :ro

如果错误是这样的:

$ docker exec order-svc touch /app/conf/a.yml
touch: cannot touch '/app/conf/a.yml': Read-only file system
$ docker inspect -f '{{range .Mounts}}{{.Destination}} rw={{.RW}} mode={{.Mode}}{{"\n"}}{{end}}' order-svc
/app/conf rw=false mode=ro
/app/logs rw=true mode=

Read-only file system 和 Permission denied 是两回事:前者是挂载本身被标记只读(-v /path:/dst:ro 或 --mount ...,readonly),root 也写不了;后者是权限位/SELinux 拒绝。看 RW=false 一眼就分清。配置文件类目录本来就该只读挂载(防止应用误改),日志/数据目录才需要读写。同样,用 --read-only 启动整个容器根文件系统时,别忘了给 /tmp、/var/run 等需要写入的路径补 --tmpfs。

二、支线 1:容器时区不对(日志差 8 小时)

复现现象

$ date                             # 宿主机
Fri Sep 26 11:32:07 CST 2026
$ docker exec order-svc date       # 容器
Fri Sep 26 03:32:07 UTC 2026

相差 8 小时,容器是 UTC。原因很朴素:绝大多数基础镜像默认时区就是 UTC,并且容器和宿主机共享同一个系统时钟,时区只影响“怎么显示”,不影响“时间本身”——所以时间戳是对的,只是格式化出来的字符串偏了 8 小时。日志里看着是凌晨 3 点,但转成 UTC+8 就是上午 11 点。

排查命令

$ docker exec order-svc printenv TZ                # 有没有设置 TZ 变量
(无输出)
$ docker exec order-svc cat /etc/timezone          # Debian/Ubuntu 系
Etc/UTC
$ docker exec order-svc ls -l /etc/localtime       # 指向哪个 zoneinfo
lrwxrwxrwx 1 root root 27 ... /etc/localtime -> /usr/share/zoneinfo/Etc/UTC
$ docker exec order-svc ls /usr/share/zoneinfo/Asia/Shanghai
ls: cannot access '/usr/share/zoneinfo/Asia/Shanghai': No such file or directory   # 没装 tzdata!

三种修法

  1. TZ 环境变量(最简单,前提:镜像里有 tzdata):
    $ docker run -d -e TZ=Asia/Shanghai demo/order-svc:1.0
    $ docker exec order-svc date
    Fri Sep 26 11:35:41 CST 2026
  2. 挂载宿主机的 /etc/localtime(只读):
    $ docker run -d -v /etc/localtime:/etc/localtime:ro -v /etc/timezone:/etc/timezone:ro demo/order-svc:1.0
    让容器和宿主机时区保持一致。注意有些发行版没有 /etc/timezone 文件,挂载不存在的文件会被创建成目录,反而出错,先 ls 确认。
  3. 烘焙进 Dockerfile(推荐,镜像自带,不依赖运行参数):
    # Debian / Ubuntu 系
    RUN apt-get update && apt-get install -y --no-install-recommends tzdata \
     && ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
     && echo "Asia/Shanghai" > /etc/timezone \
     && rm -rf /var/lib/apt/lists/*
    # Alpine:ENV TZ 本身无效,必须先装 tzdata
    RUN apk add --no-cache tzdata
    ENV TZ=Asia/Shanghai

两个易踩的小坑:

  • Alpine 没有 tzdata,只设 TZ 会静默回退为 UTC,不报任何错,最容易被忽略;
  • Java 应用最好再显式指定 -Duser.timezone=Asia/Shanghai(或 JAVA_TOOL_OPTIONS),别依赖系统层的猜测;数据库连接串里的 serverTimezone 也要一致,否则 JDBC 读写 datetime 时会再错位一次。

三、支线 2:改了代码,镜像没更新

复现现象

本地改了接口逻辑,docker build 一切正常,部署后线上还是旧行为。这个问题有好几种成因,我按“从最常见到最隐蔽”的顺序排查:

写法 A:先 COPY 全部,再装依赖 COPY . /app (任何源码变动都使这层失效) RUN mvn dependency:go-offline (重跑,下载几百 MB) RUN mvn package (重跑) 改一行代码 → 后面每一层都不命中缓存 → 又慢又浪费 写法 B:变化少的先,变化多的后 COPY pom.xml . (依赖不变则 CACHED) RUN mvn dependency:go-offline (CACHED) COPY src ./src (改代码只从这里开始重跑) RUN mvn package -DskipTests 依赖层稳定命中缓存,构建从分钟降到秒级 缓存规则 某层变了,它之后的所有层都要重建;层的“指纹”取决于指令文本与被 COPY 的文件内容 所以“改了代码没生效”常见于:变动的文件没被 COPY 进去(.dockerignore / 路径错)或容器没被重建

排查命令:一步步缩小范围

第 1 步:确认“容器里跑的”到底是不是新镜像。最常见的原因不是构建缓存,而是根本没重建容器:docker compose up -d 只会在配置或镜像 ID 变化时才重建容器,而 compose 默认不会帮你 build。

$ docker inspect -f '{{.Image}}' order-svc          # 容器实际使用的镜像 ID
sha256:4b1a9c...e02
$ docker images --no-trunc --format '{{.ID}} {{.Repository}}:{{.Tag}} {{.CreatedSince}}' | grep order-svc
sha256:8d77f0...a13 demo/order-svc:1.0 2 minutes ago    # 新镜像
sha256:4b1a9c...e02 <none>:<none>     3 days ago      # 容器还在用的旧镜像

两个 ID 不同:新镜像已经 build 出来了,但容器还在用老的。解决:

$ docker compose up -d --build                # build 并重建有变化的服务
$ docker compose up -d --force-recreate order-svc   # 强制重建容器

第 2 步:确认文件内容是不是新的。不要靠感觉,直接比对 MD5:

$ md5sum target/order-svc.jar
a91c...f3  target/order-svc.jar
$ docker exec order-svc md5sum /app/order-svc.jar
77e0...b1  /app/order-svc.jar               # 不一致:镜像里是旧包

第 3 步:看构建日志里哪些层是 CACHED。用 --progress=plain 输出完整日志:

$ docker build --progress=plain -t demo/order-svc:1.0 .
#5 [2/5] COPY pom.xml .
#5 CACHED
#6 [3/5] RUN mvn dependency:go-offline
#6 CACHED
#7 [4/5] COPY target/order-svc.jar /app/order-svc.jar
#7 CACHED                                     # ← 这一层命中缓存,说明 jar 没变
#8 [5/5] ...

COPY target/order-svc.jar 命中了缓存,意味着构建上下文里的这个 jar 字节完全没变——也就是说,你根本没有重新 mvn package(或者 CI 里打包步骤失败被吞了)。Docker 没有 bug,是你交给它的东西没变。

第 4 步:检查 .dockerignore。如果被改的文件被忽略了,它压根不会进入构建上下文,等于“改了也白改”:

$ cat .dockerignore
target/
*.md
src/main/resources/application-prod.yml      # ← 这一行把你改的配置排除了!

官方最佳实践建议使用 .dockerignore 排除与构建无关的文件,减小构建上下文;但也要小心它误伤了你需要的文件。也有相反的情形:没有 .dockerignore,.git、node_modules、日志目录全被送进上下文,每次任何一个无关文件变化都会让 COPY . . 这层失效。

第 5 步:隐蔽的缓存陷阱——RUN 指令的“外部依赖”。Docker 缓存只看指令文本,不看外部世界。所以这样写会永远缓存:

RUN git clone https://git.demo.example.com/order/config.git /conf   # 仓库更新了也不会重跑
RUN curl -o /app/rules.json https://demo.example.com/rules.json     # 远程文件变了也不会重跑
RUN apt-get update                                                   # 与 install 分开写,索引会“过期”
RUN apt-get install -y curl

官方最佳实践特别提到最后一种:apt-get update 和 apt-get install 分成两层,会导致后续修改 install 列表时读不到新的软件包索引,应合并为一条 RUN apt-get update && apt-get install -y ...。

修复与 --no-cache 的正确用法

# 怀疑缓存有问题时,作为“诊断手段”强制全量重建
$ docker build --no-cache -t demo/order-svc:1.0 .
# 官方提醒:--no-cache 不会拉取新的基础镜像,想同时更新基础镜像要加 --pull
$ docker build --pull --no-cache -t demo/order-svc:1.0 .

--no-cache 是诊断和兜底工具,不要把它写进日常构建脚本——那等于每次都重新下载所有依赖。真正的修复是把 Dockerfile 写成“稳定的在前,易变的在后”(见上图写法 B):

FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /src
COPY pom.xml .
RUN mvn -q dependency:go-offline
COPY src ./src
RUN mvn -q package -DskipTests

FROM eclipse-temurin:17-jre
COPY --from=build /src/target/order-svc.jar /app/order-svc.jar
ENTRYPOINT ["java", "-jar", "/app/order-svc.jar"]

另外,别用 latest 作为部署 tag——本地 latest 和仓库里的 latest 可能不是一个东西,机器 A 拉过、机器 B 没拉过,就会出现“同样的命令,不同的版本”。用 Git 提交号或构建号做 tag,可追溯,也不会被缓存和拉取策略坑。

四、复盘

问题排查命令根治
挂载目录 Permission denieddocker exec <c> id;ls -ln <宿主目录>;stat -c '%u:%g %a'目录属主对齐容器 UID(chown 数字);或 --user;或 named volume;不用 777
UID 对了仍拒绝(RHEL 系)getenforce;ls -Z;ausearch -m avc -ts recent挂载加 :z(共享)/ :Z(私有);绝不对系统目录用 :Z;不要关 SELinux
Read-only file systemdocker inspect -f '{{json .Mounts}}' 看 RW需要写的目录去掉 :ro;只读根文件系统时补 --tmpfs
时区差 8 小时docker exec <c> date;printenv TZ;ls /usr/share/zoneinfoTZ=Asia/Shanghai + 镜像装 tzdata;Java 加 -Duser.timezone;Alpine 别忘 tzdata
改了代码镜像没更新比对容器与镜像 ID;md5sum 对比;--progress=plain 看 CACHEDcompose 用 up -d --build;依赖与源码分层 COPY;检查 .dockerignore;用唯一 tag;--no-cache 只作诊断

一句话备忘:Permission denied 先看数字——docker exec id 对 ls -ln;数字对了看 SELinux(:z/:Z)和 :ro;时间差 8 小时是 tzdata 与 TZ;改了没生效先比容器和镜像的 ID,再看 CACHED 与 .dockerignore。

写到这里我发现,这三个坑的共同点其实是“默认值不等于你以为的值”:默认 root 建目录、默认 UTC、默认缓存。Docker 从来没骗过我们,它只是老老实实地按默认值办事——而我们习惯了让默认值替我们想问题。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人