从一个数据丢失的问题说起
前段时间在做一个Python数据处理服务,用Docker跑任务。服务逻辑很简单:容器启动后从消息队列拉任务,把中间结果写到 /app/data 目录,处理完再上传。本地开发时一切正常,但部署到测试环境后,运维反馈说容器重启后 /app/data 里的文件全没了。
我第一反应是代码里哪里的清理逻辑写错了,翻了一遍没发现问题。后来才意识到,/app/data 这个目录根本没有挂载出来,它就在容器的可写层里。容器一删,可写层跟着没,数据自然就丢了。
这个问题本身不复杂,但它牵扯出Docker存储的两个核心概念:镜像分层和卷挂载。很多刚接触Docker的人(包括当时的我)会把这两个东西混在一起理解,结果在数据持久化、镜像体积、构建缓存这些地方反复踩坑。这篇文章就把这两块拆开讲清楚,结合我自己验证过的命令和实际项目里的用法,尽量说得具体一些。
镜像分层到底是怎么叠加的

先说镜像。一个Docker镜像不是一个单独的大文件,而是一层一层叠起来的。每一层是一个只读的文件系统快照,层与层之间通过联合文件系统(Union File System)组合成一个完整的根文件系统。
在Linux上,Docker默认用的存储驱动通常是 overlay2。可以这样确认当前环境:
docker info | grep "Storage Driver"
我本机输出是 Storage Driver: overlay2,这是目前主流Linux发行版上的默认值。如果你在macOS或Windows上用Docker Desktop,底层其实是在一个Linux虚拟机里跑,驱动同样是overlay2。
分层是怎么产生的
每个 RUN、COPY、ADD 指令都会产生一个新层。看一个简单的Dockerfile:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install –no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]
构建完之后用 docker history 能看到每一层的来源和大小:
docker history myapp:latest
输出里会列出每一层对应的指令、创建时间、大小。pip install 那一层通常最大,因为装的包都在这层。COPY . . 那层只包含代码变更部分。
这里有个容易忽略的点:每一层只记录相对于上一层的差异。比如第一层装了requests,第二层又装了flask,第二层不会重复记录requests,只记录flask相关的文件。这就是为什么多个镜像共享基础层时,磁盘占用不会线性增长。
可写层:容器自己的那层
镜像的所有层都是只读的。容器启动时,Docker会在镜像最上面再加一个可写层(也叫容器层)。所有对容器的写操作——改文件、建目录、删东西——都发生在这一层。
关键机制在这里:当容器要修改一个存在于镜像层里的文件时,overlay2会先把那个文件从只读层复制到可写层,然后再改。这个操作叫copy-on-write(写时复制)。读操作不受影响,直接读只读层。
用个具体例子验证一下。启动一个容器,往 /app 写个文件:
docker run -it –name test-layer python:3.11-slim bash
# 容器内执行
echo "hello" > /app/test.txt
这时候 /app/test.txt 只存在于可写层。退出容器(不删),重新 docker start 进去,文件还在,因为可写层还在。但只要 docker rm 掉容器,可写层销毁,文件就没了。
这就是文章开头那个问题的根因:可写层跟着容器生命周期走,容器删了数据就没了。
【关键结论】镜像层只读且可共享,容器可写层独享且随容器销毁。需要跨容器生命周期保留的数据,必须放到卷里。
卷挂载:把数据从可写层里"捞"出来

卷(Volume)本质上是一个独立于容器可写层的存储位置,挂载到容器的某个路径上。对容器来说,往挂载点写数据,实际写到的是宿主机上的某个目录,而不是可写层。
Docker里主要有三种挂载方式:
| bind mount | 直接映射宿主机路径,方便查看和编辑 | 依赖宿主机目录结构,跨平台一致性差 | 开发环境代码同步、配置文件注入 |
| named volume | Docker管理,跨平台一致,性能好 | 位置不直观,需用docker volume命令查看 | 数据库数据、生产环境持久化 |
| tmpfs mount | 内存存储,读写快 | 容器停止即丢失,占内存 | 临时缓存、敏感数据不留盘 |
bind mount的实际用法
bind mount最直接的写法是 -v 加上宿主机绝对路径:
docker run -d \\
–name myapp \\
-v /home/user/appdata:/app/data \\
myapp:latest
这样 /app/data 里的内容实际存在宿主机的 /home/user/appdata。容器删了,数据还在。
开发时经常用这种方式把代码目录挂进去,改完代码不用重新构建镜像:
docker run -d \\
-v $(pwd)/src:/app/src \\
-p 8000:8000 \\
myapp:latest
named volume的用法
named volume不用指定宿主机路径,Docker自己管理:
docker volume create appdata
docker run -d \\
–name myapp \\
-v appdata:/app/data \\
myapp:latest
想知道数据实际存在哪,可以查:
docker volume inspect appdata
输出里的 Mountpoint 字段就是宿主机上的实际路径,一般在 /var/lib/docker/volumes/ 下面。
一个容易搞混的细节:匿名卷
如果只写容器路径不写来源,比如 -v /app/data,Docker会创建一个匿名卷。用 docker volume ls 能看到一堆哈希命名的卷。这些卷在容器删除时默认不会自动清理(除非用 –rm 或者手动 docker volume prune),时间长了会占不少磁盘。
我个人习惯是:生产环境用named volume,开发环境用bind mount,尽量不用匿名卷。
挂载覆盖:为什么我的镜像文件不见了

这是我自己踩过的一个坑。有个镜像在构建时往 /app/config 放了一份默认配置:
COPY config/default.yaml /app/config/default.yaml
运行时想用宿主机的配置覆盖,就这么挂:
docker run -v $(pwd)/myconfig:/app/config myapp:latest
结果容器里 /app/config 只剩 myconfig 目录里的东西,default.yaml 不见了。
原因是:挂载会覆盖目标路径的原有内容。bind mount和named volume都一样。挂载点下面原本镜像层里的文件,会被挂载源的内容完全遮住。
这一点和很多人直觉相反。直觉上觉得"挂载是叠加",实际是"替换"。
解决办法有几种:
我现在的做法是配置目录不预先放文件,镜像只提供一份示例配置放在别的位置(比如 /app/config.example),运行时由启动脚本判断:如果挂载目录为空,就把示例拷过去。
#!/bin/sh
if [ -z "$(ls -A /app/config)" ]; then
cp /app/config.example/* /app/config/
fi
exec "$@"
【踩坑提醒】挂载是覆盖不是叠加。挂载点下面的镜像内容会被完全遮住,不是合并。
挂载路径的权限问题

另一个实际遇到过的问题是权限。用bind mount挂宿主机目录时,容器内进程的用户ID和宿主机目录的属主如果不一致,就会出现写不进去的情况。
比如容器里用非root用户跑(这是好习惯),UID是1000,但宿主机上挂载的目录属主是root,权限755。容器里一写就报Permission denied。
这个问题没有一劳永逸的解法,常见处理方式:
我一般在Dockerfile里显式指定UID:
FROM python:3.11-slim
RUN useradd -m -u 1000 appuser
WORKDIR /app
COPY –chown=appuser:appuser . .
USER appuser
然后确保宿主机上挂载的目录属主也是UID 1000,或者用 chown 调整一下。
named volume就没这个问题,因为Docker创建卷时会处理权限。这也是生产环境我更倾向用named volume的原因之一。
镜像体积优化:分层带来的连锁反应
理解了分层,镜像体积优化就有方向了。因为每层只记录差异,所以:
把不常变的内容放前面,常变的放后面。 这样构建时能复用缓存,也避免每次改动都重装依赖。
一个反例:
FROM python:3.11-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
每次改一行代码,COPY . . 这层就变了,后面 pip install 的缓存也失效,每次都要重装依赖。改成先拷requirements再装包,缓存就能复用:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install –no-cache-dir -r requirements.txt
COPY . .
另外,RUN 里临时下载的文件如果在同一层里删掉,其实不减小体积,因为那一层的差异记录里包含了"下载"和"删除"两个动作。要减小体积得合并到一条 RUN 里,或者用多阶段构建。
多阶段构建是更彻底的办法:构建阶段装编译工具和依赖,运行阶段只拷最终产物。
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install –user –no-cache-dir -r requirements.txt
FROM python:3.11-slim
WORKDIR /app
COPY –from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "main.py"]
这样最终镜像里不带pip的构建缓存,体积能小不少。具体小多少取决于依赖,我没有做严格的对比测试,但方向是明确的。
数据持久化的方案选择
回到开头那个数据丢失的问题,最终的方案是给 /app/data 挂一个named volume:
docker run -d \\
–name processor \\
-v processor-data:/app/data \\
processor:latest
这样容器重启、重建,数据都在卷里。需要备份时:
docker run –rm \\
-v processor-data:/data \\
-v $(pwd)/backup:/backup \\
alpine tar czf /backup/data.tar.gz -C /data .
用一个临时容器把卷挂进去打包,导出到宿主机。恢复反过来操作。
什么数据该挂卷
不是所有数据都需要持久化。我的判断标准:
- 必须持久化:数据库文件、用户上传的文件、任务中间状态、日志(如果需要长期保留)
- 不需要持久化:缓存、临时文件、可以从源头重建的数据
把不需要的也挂卷,反而增加管理成本,还可能因为卷里残留旧数据导致奇怪问题。
卷的生命周期
docker rm 删容器不会删named volume。要删卷得单独操作:
docker volume rm processor-data
或者清理所有没被使用的卷:
docker volume prune
这个设计是合理的——防止误删数据。但反过来说,如果不注意,会积累一堆没用的卷占磁盘。我习惯定期 docker volume ls 看一眼。
几个值得留意的细节
容器内看到的文件系统是"合并视图"。ls 看到的既有镜像层的内容,也有可写层的内容,还有挂载的内容。但 docker diff 只显示可写层相对于镜像层的改动,挂载的内容不算在内。
挂载路径必须是绝对路径。-v data:/app/data 里的 data 如果不是已存在的named volume,Docker会当成named volume创建;但如果是 ./data:/app/data,相对路径会被当成bind mount。这个行为容易混淆,建议写清楚。
–mount 语法比 -v 更明确。新版本Docker推荐用 –mount,参数名清晰,出错时提示也更好:
docker run -d \\
–mount type=volume,source=processor-data,target=/app/data \\
processor:latest
功能上和 -v 等价,但可读性更好。我在脚本里更倾向用这个。
overlay2的性能。写时复制的机制意味着首次写一个大文件会有拷贝开销。对于写密集的场景,挂载卷(尤其是named volume)通常比写在可写层性能更好。这一点我在实际项目里感受到过,但没有做过精确的基准测试,只能说方向如此,具体数字因场景而异。
写在最后
Docker的存储机制本身不复杂,核心就是"分层只读+可写层+卷挂载"这三件事。但实际用起来,挂载覆盖、权限、生命周期这些细节容易出问题。
我的经验是:开发和生产的挂载策略要分开考虑。开发图方便,用bind mount同步代码;生产图稳定,用named volume存数据。别把开发时的习惯带到生产,也别为了省事把该持久化的数据留在可写层。
至于开头那个数据丢失的问题,事后想想其实是个低级错误,但它让我把Docker存储这块彻底理清了一遍。如果你也在用Docker跑有状态服务,建议检查一下关键数据目录是不是都正确挂载了——这个问题不查没事,一查可能就发现几个漏网的。
网硕互联帮助中心



评论前必须登录!
注册