这次不讲惊天动地的大事故,讲三个“小坑合集”。它们有个共同点:每一个都不至于让系统宕机,但每一个都能在你上线的凌晨让你怀疑人生。
第一个是主角:“挂载目录 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)。文件权限比较的是数字,不是用户名。
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 mount | named 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 logsservices:
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!三种修法
- 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 - 挂载宿主机的
/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确认。 - 烘焙进 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 一切正常,部署后线上还是旧行为。这个问题有好几种成因,我按“从最常见到最隐蔽”的顺序排查:
排查命令:一步步缩小范围
第 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 denied | docker 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 system | docker inspect -f '{{json .Mounts}}' 看 RW | 需要写的目录去掉 :ro;只读根文件系统时补 --tmpfs |
| 时区差 8 小时 | docker exec <c> date;printenv TZ;ls /usr/share/zoneinfo | TZ=Asia/Shanghai + 镜像装 tzdata;Java 加 -Duser.timezone;Alpine 别忘 tzdata |
| 改了代码镜像没更新 | 比对容器与镜像 ID;md5sum 对比;--progress=plain 看 CACHED | compose 用 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 从来没骗过我们,它只是老老实实地按默认值办事——而我们习惯了让默认值替我们想问题。
参考资料
- Docker 官方文档:Bind mounts——bind mount 默认可写、
ro只读选项、SELinux 的:z/:Z含义及对/home、/usr使用:Z的警告。 - Docker 官方文档:Volumes——named volume 存放位置、空卷挂到非空目录时内容被复制、只读挂载。
- Docker 官方文档:docker run 参考——
-u支持 uid:gid 形式及取值范围说明。 - Docker 官方文档:Build cache——层缓存失效规则:某层变化则其后所有层重建,以及 COPY 顺序的优化思路。
- Docker 官方文档:Building best practices——.dockerignore、层顺序、
--no-cache与--pull,以及 apt-get update 与 install 合并写的原因。 - Dash0:How to manage permissions for Docker shared volumes——用 docker exec id 与 ls -ln 对比 UID/GID,以及 --user、COPY --chown、named volume 初始化三种修法。
- OneUptime:How to Set Container Timezone in Docker——TZ 环境变量、挂载 /etc/localtime、Dockerfile 装 tzdata 三种方法,以及 Java 需另设 -Duser.timezone。
一起聊聊