
前段时间在调一个 Python 服务遇到一个挺典型的现象容器里改了一个配置文件docker restart之后改动没了变回镜像里的原始内容。当时第一反应是“是不是没保存”后来才意识到问题出在我对 Docker 存储模型的理解上——我一直把容器当成一台“小虚拟机”但它其实更像一个叠加出来的视图。这篇文章就从这个现象出发把镜像分层、写时复制Copy-on-Write、以及三种挂载方式讲清楚。不堆概念尽量说清楚“为什么会这样”和“实际写代码时该注意什么”。一、先把现象复现出来用一个最小的 Python 镜像来复现。假设目录结构是这样demo/ ├── Dockerfile └── app.pyapp.py里只做一件事读一个配置文件的某一行# app.pyfrompathlibimportPath CONFIGPath(/app/config.ini)defmain():textCONFIG.read_text(encodingutf-8)print(当前配置内容)print(text)if__name____main__:main()Dockerfile如下基础镜像用python:3.11-slim这是官方镜像的常见 tag具体拉取到的 digest 会随时间变化这一点我不去断言某个固定 digestFROM python:3.11-slim WORKDIR /app COPY config.ini /app/config.ini COPY app.py /app/app.py CMD [python, app.py]config.ini内容随便写一行modeprod构建并运行dockerbuild-tdemo-cow:1.0.dockerrun--rmdemo-cow:1.0输出是modeprod没问题。接下来进入容器改一下dockerrun-it--namedemo-cow demo-cow:1.0bash# 容器内echomodedev/app/config.inicat/app/config.ini这时看到的是modedev。然后另开一个终端docker restart demo-cow再docker exec进去看dockerexecdemo-cowcat/app/config.ini结果是modeprod改动没了。我第一次遇到时以为是自己命令写错了实际上这是预期行为。原因就在镜像分层。二、镜像分层到底是怎么叠出来的Docker 镜像不是一个整块文件而是一层层只读层layer叠加起来的。每一条会改变文件系统的Dockerfile指令通常会产生一层。比如上面的Dockerfile大致会形成层次来源指令内容底层FROM python:3.11-slim基础系统与 Python 运行时中间层WORKDIR /app目录元数据中间层COPY config.ini配置文件中间层COPY app.py应用代码容器启动时Docker 会在这些只读层之上再加一个可写层container layer。容器里所有的写操作落到的都是这个可写层而不是下面那些只读层。用docker inspect能看到挂载信息里的LowerDir、UpperDir、MergedDirdockerinspect demo-cow--format{{json .GraphDriver.Data}}|python-mjson.tool在 overlay2 驱动下输出里会有类似这样的字段路径因环境不同而不同这里只说明结构{LowerDir:/var/lib/docker/overlay2/.../diff:/var/lib/docker/overlay2/.../diff,MergedDir:/var/lib/docker/overlay2/.../merged,UpperDir:/var/lib/docker/overlay2/.../diff,WorkDir:/var/lib/docker/overlay2/.../work}LowerDir一个或多个只读层冒号分隔顺序从上层到下层。UpperDir容器的可写层。MergedDir把上面两者叠加后呈现给容器的统一视图容器里看到的/app/config.ini就是从这里来的。【关键结论】容器内看到的文件系统 只读层 可写层 的叠加视图。你在容器里改文件改的是可写层里的副本docker restart不会清掉可写层但如果你是用docker run --rm或重建容器可写层就没了改动自然消失。这里有个容易混淆的点docker restart保留可写层docker rm之后重建则丢弃。我上面复现时用的是docker run -it --name demo-cow重启后改动还在可写层里为什么看到的是modeprod原因是我在容器里执行的是echo modedev /app/config.ini。这个重定向会先截断再写入对于 overlay2 来说第一次写这个文件时会触发 Copy-up把文件从只读层复制到可写层然后再改。理论上可写层里应该存的是modedev。但我当时实际用的命令和观察有出入为了不给读者留下不确定的结论这里我重新用更严谨的方式验证一下。三、用更严谨的方式验证写时复制重新做一次每一步都记录清楚。先起一个容器并保持运行dockerrun-d--namecow-test demo-cow:1.0sleep3600确认初始内容dockerexeccow-testcat/app/config.ini# modeprod改内容dockerexeccow-testsh-cecho modedev /app/config.inidockerexeccow-testcat/app/config.ini# modedev重启容器dockerrestart cow-testdockerexeccow-testcat/app/config.ini这次我实际看到的是modedev改动保留。也就是说docker restart不会丢弃可写层。那什么情况下改动会丢两种容器被删除后重建docker rmdocker run。改动写在了挂载点覆盖的路径上而挂载源没有持久化。我最初那次看到“变回去”回想起来应该是用了docker run --rm进程退出后容器被自动删除可写层一起没了下次docker run又是全新的可写层。这个细节当时没注意才误以为是“重启丢失”。【踩坑提醒】判断改动会不会丢先问自己容器是被删除重建了还是只是重启这两者行为完全不同。四、挂载让数据跳出可写层可写层有个现实问题它跟着容器生命周期走容器删了数据就没了。而且可写层和只读层叠加IO 路径比直接读写宿主机目录要绕。生产环境里需要持久化的数据一般不会放在可写层而是通过挂载把宿主机或 Docker 管理的存储接进来。Docker 常见的挂载方式有三种行为差异挺大方案优点缺点适用场景bind mount直接映射宿主机路径改动即时可见方便调试依赖宿主机目录结构跨机器移植差权限容易出问题开发环境挂代码、挂配置volume由 Docker 管理跨平台一致生命周期可独立于容器位置不直观需要docker volume命令管理数据库数据、需要持久化的业务数据tmpfs写在内存速度快不落盘容器停止即丢失占内存临时缓存、敏感数据临时存放bind mount 的覆盖行为bind mount 最容易踩的一点是挂载会覆盖目标路径原有的内容。比如dockerrun-d--namebind-test\-v/host/config:/app/config\demo-cow:1.0sleep3600如果镜像里/app/config本来有文件挂上宿主机目录后容器里看到的是宿主机目录的内容镜像里那份被“盖住”了。不是合并是覆盖。我见过有人把代码目录挂进去结果镜像里pip install装的依赖被一起盖掉容器起不来。原因就是COPY进去的依赖和挂载目录在同一个路径下。volume 的用法volume 用起来更“Docker 原生”dockervolume create app-datadockerrun-d--namevol-test\-vapp-data:/app/data\demo-cow:1.0sleep3600volume 首次挂载到一个非空目录时Docker 会把镜像里该目录的内容复制到 volume 中这个行为叫 volume populate。但如果是 bind mount就不会有这个复制动作。这个差异在实际使用中影响挺大尤其是初始化数据库目录时。Python 场景下的选择对于 Python 服务我一般的做法是代码构建进镜像不打 bind mount保证环境一致。配置文件开发时用 bind mount 方便改生产用环境变量或配置中心。数据如 SQLite 文件、上传目录用 volume。临时文件用 tmpfs或者干脆放/tmp。五、Dockerfile 分层顺序对构建的影响既然每条指令一层那么层的顺序会直接影响构建缓存命中。看两个写法# 写法 A FROM python:3.11-slim WORKDIR /app COPY . /app RUN pip install -r requirements.txt CMD [python, main.py]# 写法 B FROM python:3.11-slim WORKDIR /app COPY requirements.txt /app/ RUN pip install -r requirements.txt COPY . /app CMD [python, main.py]写法 A 里COPY . /app只要有任何源码改动缓存就失效后面的pip install会重新执行。写法 B 把依赖安装提前源码改动不会影响依赖层构建速度差别很明显。注意COPY . /app会把当前目录所有东西拷进去包括__pycache__、.venv、.git。这些应该通过.dockerignore排除__pycache__/ *.pyc .venv/ .git/ .env【注意】.dockerignore的匹配规则和.gitignore不完全一样写的时候建议对照官方文档确认不要凭直觉。六、几个实际排查中用到的命令排查容器文件系统问题时这几个命令比较有用看容器的挂载信息dockerinspectcontainer--format{{json .Mounts}}|python-mjson.tool看存储驱动的目录结构dockerinspectcontainer--format{{json .GraphDriver.Data}}|python-mjson.tool看镜像分层dockerhistoryimagedocker history能列出每一层的大小和来源指令。有一类常见问题是层数太多、体积膨胀用它一眼能看出来。查看 volume 实际位置dockervolume inspectvolume输出里的Mountpoint就是宿主机上的路径。七、关于“叠加态”和“多维空间”的一点说明选题里用了“叠加态”“多维空间穿梭”这样的说法作为比喻可以但落到工程上要清楚Docker 的分层不是量子叠加它就是几个目录通过 overlay 文件系统合并成一个视图。容器里ls看到的文件可能来自任意一个只读层也可能来自可写层。写时复制保证了只读层不被修改代价是第一次写文件时要复制一份。理解这一点之后很多“奇怪”现象就有解释了为什么容器删了数据就没了——可写层跟着容器走。为什么挂载后镜像里的文件不见了——挂载覆盖了目标路径。为什么构建有时候快有时候慢——层缓存命不命中。为什么镜像越做越大——层没合并、临时文件没清理。八、一点实践建议如果你的 Python 服务要上容器我的建议是依赖安装和源码拷贝分开写利用层缓存。用.dockerignore控制构建上下文。需要持久化的数据一律走 volume 或外部存储不要指望可写层。开发时用 bind mount 挂配置生产不要挂源码。排查文件问题时先docker inspect看Mounts和GraphDriver比猜快得多。有一个我还没深入验证的点不同存储驱动overlay2、btrfs、zfs在写时复制的具体实现和性能表现上有差异overlay2 是目前 Linux 上的默认选择但如果你在特殊环境下用了别的驱动行为可能不完全一样。这一块我没有做系统性对比测试就不给结论了。总的来说Docker 的存储模型不难难的是它和“虚拟机”直觉之间的落差。把分层和挂载这两件事想清楚大部分文件相关的诡异问题都能定位。