在 docker-compose.yml 里加了 volumes 挂载,docker compose up 一启动,容器里原本有的文件全没了——Nginx 的默认页面不见了,直接 403。

这个坑几乎所有用 Docker Compose 的人都踩过。文件其实没丢,是被「遮蔽」了。这篇文章用一个可复现的实验讲清楚原理,再给 3 个相关坑的避坑方法。所有命令在 Docker 29.8 + Compose v5.5 上实测过。

复现:30 秒看到「文件消失」

准备一个空的 html 目录,写一份 compose 文件:

services:
  web:
    image: nginx:alpine
    ports:
      - "18080:80"
    volumes:
      - ./html:/usr/share/nginx/html

启动后看容器里的挂载点:

docker compose up -d
docker compose exec web ls -la /usr/share/nginx/html

实测输出——空的:

total 8
drwxr-xr-x    2 root     root          4096 ...
drwxr-xr-x    1 root     root          4096 ...

而镜像本身明明有默认文件,不挂载直接跑就能看见:

docker run --rm nginx:alpine ls /usr/share/nginx/html
# 50x.html  index.html

访问 http://localhost:18080 直接 403 Forbidden——目录里连 index.html 都没有。

原理:bind mount 是「遮蔽」,不是「合并」

./html:/usr/share/nginx/html 这种写法叫 bind mount(绑定挂载)。它的行为是:把宿主机目录直接盖在容器路径上,原来路径下的内容被完全遮住。

注意三个关键点:

  1. 镜像里的文件没有被删除,只是看不见了——不挂载启动容器,文件还在
  2. 遮蔽是整目录替换,不做任何文件级合并,哪怕宿主机目录是空的
  3. 你往 ./html 里放文件,容器里立刻可见——因为容器看到的就是这个宿主机目录

所以正确用法是:把需要的内容先放进宿主机目录再挂载,而不是指望镜像内容和挂载目录「合并」。

对照实验:named volume 的行为完全不同

把 bind mount 换成 named volume:

services:
  web:
    image: nginx:alpine
    volumes:
      - webdata:/usr/share/nginx/html

volumes:
  webdata:

实测:容器里 50x.html 和 index.html 都在。

差别在于初始化时机:named volume 首次创建时,Docker 会把镜像挂载点下的内容复制进 volume;之后 volume 里已有的内容保留,不再同步。而 bind mount 永远不做这个复制——宿主机给什么就是什么。

一句话记忆:

挂载方式 首次启动时 适合场景
bind mount(./dir:/path) 宿主机内容直接遮蔽镜像 配置文件、代码目录(内容本来就在宿主机)
named volume(name:/path) 镜像内容先复制进 volume 数据库数据、上传文件(想要镜像初始内容做底)

坑 2:宿主机路径不存在,Docker 会静默创建空目录

写错路径没有报错:

volumes:
  - ./htm:/usr/share/nginx/html   # 手滑,少了个 l

docker compose up 正常启动,Docker 默默在宿主机创建了 ./htm 空目录,然后把容器内容遮成空。你盯着配置文件看半天也想不到是路径打错了。

排查方法:

docker compose exec web ls /usr/share/nginx/html   # 看容器里
docker inspect <容器名> --format '{{json .Mounts}}' | jq   # 看实际挂载源

Mounts 里的 Source 字段是宿主机绝对路径,一对照就能发现路径写错了。

坑 3:挂载单个文件,文件不存在会变成目录

想只挂一个配置文件:

volumes:
  - ./nginx.conf:/etc/nginx/nginx.conf

如果宿主机上 ./nginx.conf 不存在,Docker 会把它创建成目录,容器里 cat /etc/nginx/nginx.conf 报 Is a directory。

规避:挂载单文件前,先确认宿主机上这个文件真实存在:

# 没有就先建一个,哪怕空文件
touch nginx.conf

相对路径 ./xxx 是相对 compose 文件所在目录解析的,不是你执行命令的目录——在别处敲 docker compose -f /path/to/docker-compose.yml up 时尤其容易搞错。

坑 4:Linux 上的权限问题

bind mount 进容器的目录,属主是宿主机的 UID。容器进程如果以非 root 运行(比如 node、postgres 镜像),可能写不进去,报 Permission denied。

两种解法:

# 方法一:指定容器进程 UID(先看宿主机目录属主 id -u)
services:
  app:
    user: "1000:1000"

# 方法二:给宿主机目录放权
chmod -R 777 ./data    # 粗暴,仅本地开发用

Windows 的 Docker Desktop 走 WSL2 虚拟机做中转,权限问题基本被抹平了——这也是 Windows 上跑 Docker 反而少踩一类坑的罕见例子。

一张排查清单

遇到「挂载后文件没了 / 内容不对」,按这个顺序查:

# 1. 看容器里实际挂成了什么
docker inspect <容器> --format '{{json .Mounts}}' | jq

# 2. 确认宿主机路径内容是不是你想的
ls -la ./html

# 3. 确认容器里看到的内容
docker compose exec web ls -la /usr/share/nginx/html

# 4. 对照镜像原始内容(文件其实没丢)
docker run --rm nginx:alpine ls /usr/share/nginx/html

第 4 步最有说服力——镜像内容还在,就证明只是被遮蔽,问题一定出在挂载配置上。

更多 Docker 命令参数看 Docker 命令速查 和 Docker Compose 速查。