云计算百科
云计算领域专业知识百科平台

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

从一个数据丢失的问题说起

前段时间在做一个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都一样。挂载点下面原本镜像层里的文件,会被挂载源的内容完全遮住。

这一点和很多人直觉相反。直觉上觉得"挂载是叠加",实际是"替换"。

解决办法有几种:

  • 挂载到不同的路径,代码里做配置合并
  • 用 docker cp 先把默认配置拷出来,再挂载
  • 构建时就不放默认配置,完全依赖挂载
  • 我现在的做法是配置目录不预先放文件,镜像只提供一份示例配置放在别的位置(比如 /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。

    这个问题没有一劳永逸的解法,常见处理方式:

  • 构建镜像时创建和宿主机一致UID的用户
  • 启动时用 -u $(id -u):$(id -g) 指定用户
  • 宿主机上调整目录权限
  • 我一般在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跑有状态服务,建议检查一下关键数据目录是不是都正确挂载了——这个问题不查没事,一查可能就发现几个漏网的。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Docker镜像分层与卷挂载实战:从存储原理到数据持久化踩坑记录
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!