ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Docker镜像分层与卷挂载实战:从存储原理到数据持久化踩坑记录

Docker镜像分层与卷挂载实战:从存储原理到数据持久化踩坑记录 从一个数据丢失的问题说起前段时间在做一个Python数据处理服务用Docker跑任务。服务逻辑很简单容器启动后从消息队列拉任务把中间结果写到/app/data目录处理完再上传。本地开发时一切正常但部署到测试环境后运维反馈说容器重启后/app/data里的文件全没了。我第一反应是代码里哪里的清理逻辑写错了翻了一遍没发现问题。后来才意识到/app/data这个目录根本没有挂载出来它就在容器的可写层里。容器一删可写层跟着没数据自然就丢了。这个问题本身不复杂但它牵扯出Docker存储的两个核心概念镜像分层和卷挂载。很多刚接触Docker的人包括当时的我会把这两个东西混在一起理解结果在数据持久化、镜像体积、构建缓存这些地方反复踩坑。这篇文章就把这两块拆开讲清楚结合我自己验证过的命令和实际项目里的用法尽量说得具体一些。镜像分层到底是怎么叠加的先说镜像。一个Docker镜像不是一个单独的大文件而是一层一层叠起来的。每一层是一个只读的文件系统快照层与层之间通过联合文件系统Union File System组合成一个完整的根文件系统。在Linux上Docker默认用的存储驱动通常是overlay2。可以这样确认当前环境dockerinfo|grepStorage Driver我本机输出是Storage Driver: overlay2这是目前主流Linux发行版上的默认值。如果你在macOS或Windows上用Docker Desktop底层其实是在一个Linux虚拟机里跑驱动同样是overlay2。分层是怎么产生的每个RUN、COPY、ADD指令都会产生一个新层。看一个简单的DockerfileFROM 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能看到每一层的来源和大小dockerhistorymyapp:latest输出里会列出每一层对应的指令、创建时间、大小。pip install那一层通常最大因为装的包都在这层。COPY . .那层只包含代码变更部分。这里有个容易忽略的点每一层只记录相对于上一层的差异。比如第一层装了requests第二层又装了flask第二层不会重复记录requests只记录flask相关的文件。这就是为什么多个镜像共享基础层时磁盘占用不会线性增长。可写层容器自己的那层镜像的所有层都是只读的。容器启动时Docker会在镜像最上面再加一个可写层也叫容器层。所有对容器的写操作——改文件、建目录、删东西——都发生在这一层。关键机制在这里当容器要修改一个存在于镜像层里的文件时overlay2会先把那个文件从只读层复制到可写层然后再改。这个操作叫copy-on-write写时复制。读操作不受影响直接读只读层。用个具体例子验证一下。启动一个容器往/app写个文件dockerrun-it--nametest-layer python:3.11-slimbash# 容器内执行echohello/app/test.txt这时候/app/test.txt只存在于可写层。退出容器不删重新docker start进去文件还在因为可写层还在。但只要docker rm掉容器可写层销毁文件就没了。这就是文章开头那个问题的根因可写层跟着容器生命周期走容器删了数据就没了。【关键结论】镜像层只读且可共享容器可写层独享且随容器销毁。需要跨容器生命周期保留的数据必须放到卷里。卷挂载把数据从可写层里捞出来卷Volume本质上是一个独立于容器可写层的存储位置挂载到容器的某个路径上。对容器来说往挂载点写数据实际写到的是宿主机上的某个目录而不是可写层。Docker里主要有三种挂载方式方案优点缺点适用场景bind mount直接映射宿主机路径方便查看和编辑依赖宿主机目录结构跨平台一致性差开发环境代码同步、配置文件注入named volumeDocker管理跨平台一致性能好位置不直观需用docker volume命令查看数据库数据、生产环境持久化tmpfs mount内存存储读写快容器停止即丢失占内存临时缓存、敏感数据不留盘bind mount的实际用法bind mount最直接的写法是-v加上宿主机绝对路径dockerrun-d\--namemyapp\-v/home/user/appdata:/app/data\myapp:latest这样/app/data里的内容实际存在宿主机的/home/user/appdata。容器删了数据还在。开发时经常用这种方式把代码目录挂进去改完代码不用重新构建镜像dockerrun-d\-v$(pwd)/src:/app/src\-p8000:8000\myapp:latestnamed volume的用法named volume不用指定宿主机路径Docker自己管理dockervolume create appdatadockerrun-d\--namemyapp\-vappdata:/app/data\myapp:latest想知道数据实际存在哪可以查dockervolume inspect appdata输出里的Mountpoint字段就是宿主机上的实际路径一般在/var/lib/docker/volumes/下面。一个容易搞混的细节匿名卷如果只写容器路径不写来源比如-v /app/dataDocker会创建一个匿名卷。用docker volume ls能看到一堆哈希命名的卷。这些卷在容器删除时默认不会自动清理除非用--rm或者手动docker volume prune时间长了会占不少磁盘。我个人习惯是生产环境用named volume开发环境用bind mount尽量不用匿名卷。挂载覆盖为什么我的镜像文件不见了这是我自己踩过的一个坑。有个镜像在构建时往/app/config放了一份默认配置COPY config/default.yaml /app/config/default.yaml运行时想用宿主机的配置覆盖就这么挂dockerrun-v$(pwd)/myconfig:/app/config myapp:latest结果容器里/app/config只剩myconfig目录里的东西default.yaml不见了。原因是挂载会覆盖目标路径的原有内容。bind mount和named volume都一样。挂载点下面原本镜像层里的文件会被挂载源的内容完全遮住。这一点和很多人直觉相反。直觉上觉得挂载是叠加实际是替换。解决办法有几种挂载到不同的路径代码里做配置合并用docker cp先把默认配置拷出来再挂载构建时就不放默认配置完全依赖挂载我现在的做法是配置目录不预先放文件镜像只提供一份示例配置放在别的位置比如/app/config.example运行时由启动脚本判断如果挂载目录为空就把示例拷过去。#!/bin/shif[-z$(ls-A/app/config)];thencp/app/config.example/* /app/config/fiexec$【踩坑提醒】挂载是覆盖不是叠加。挂载点下面的镜像内容会被完全遮住不是合并。挂载路径的权限问题另一个实际遇到过的问题是权限。用bind mount挂宿主机目录时容器内进程的用户ID和宿主机目录的属主如果不一致就会出现写不进去的情况。比如容器里用非root用户跑这是好习惯UID是1000但宿主机上挂载的目录属主是root权限755。容器里一写就报Permission denied。这个问题没有一劳永逸的解法常见处理方式构建镜像时创建和宿主机一致UID的用户启动时用-u $(id -u):$(id -g)指定用户宿主机上调整目录权限我一般在Dockerfile里显式指定UIDFROM python:3.11-slim RUN useradd -m -u 1000 appuser WORKDIR /app COPY --chownappuser: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 --frombuilder /root/.local /root/.local COPY . . ENV PATH/root/.local/bin:$PATH CMD [python, main.py]这样最终镜像里不带pip的构建缓存体积能小不少。具体小多少取决于依赖我没有做严格的对比测试但方向是明确的。数据持久化的方案选择回到开头那个数据丢失的问题最终的方案是给/app/data挂一个named volumedockerrun-d\--nameprocessor\-vprocessor-data:/app/data\processor:latest这样容器重启、重建数据都在卷里。需要备份时dockerrun--rm\-vprocessor-data:/data\-v$(pwd)/backup:/backup\alpinetarczf /backup/data.tar.gz-C/data.用一个临时容器把卷挂进去打包导出到宿主机。恢复反过来操作。什么数据该挂卷不是所有数据都需要持久化。我的判断标准必须持久化数据库文件、用户上传的文件、任务中间状态、日志如果需要长期保留不需要持久化缓存、临时文件、可以从源头重建的数据把不需要的也挂卷反而增加管理成本还可能因为卷里残留旧数据导致奇怪问题。卷的生命周期docker rm删容器不会删named volume。要删卷得单独操作dockervolumermprocessor-data或者清理所有没被使用的卷dockervolume prune这个设计是合理的——防止误删数据。但反过来说如果不注意会积累一堆没用的卷占磁盘。我习惯定期docker volume ls看一眼。几个值得留意的细节容器内看到的文件系统是合并视图。ls看到的既有镜像层的内容也有可写层的内容还有挂载的内容。但docker diff只显示可写层相对于镜像层的改动挂载的内容不算在内。挂载路径必须是绝对路径。-v data:/app/data里的data如果不是已存在的named volumeDocker会当成named volume创建但如果是./data:/app/data相对路径会被当成bind mount。这个行为容易混淆建议写清楚。--mount语法比-v更明确。新版本Docker推荐用--mount参数名清晰出错时提示也更好dockerrun-d\--mounttypevolume,sourceprocessor-data,target/app/data\processor:latest功能上和-v等价但可读性更好。我在脚本里更倾向用这个。overlay2的性能。写时复制的机制意味着首次写一个大文件会有拷贝开销。对于写密集的场景挂载卷尤其是named volume通常比写在可写层性能更好。这一点我在实际项目里感受到过但没有做过精确的基准测试只能说方向如此具体数字因场景而异。写在最后Docker的存储机制本身不复杂核心就是分层只读可写层卷挂载这三件事。但实际用起来挂载覆盖、权限、生命周期这些细节容易出问题。我的经验是开发和生产的挂载策略要分开考虑。开发图方便用bind mount同步代码生产图稳定用named volume存数据。别把开发时的习惯带到生产也别为了省事把该持久化的数据留在可写层。至于开头那个数据丢失的问题事后想想其实是个低级错误但它让我把Docker存储这块彻底理清了一遍。如果你也在用Docker跑有状态服务建议检查一下关键数据目录是不是都正确挂载了——这个问题不查没事一查可能就发现几个漏网的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进