穿越 Docker 内核迷雾:镜像分层的叠加态与卷挂载的空间穿梭

很多 Docker 使用者对存储的理解停留在口号层面:容器一删,刚写进去的日志、缓存和临时文件就全部蒸发 ,重新 run 一次镜像,之前 echo 进去的内容不见了。这不是 bug,而是分层存储按设计运行的结果。

常规的背概念式学习不够用。只知道"镜像只读、容器可写",解释不了为什么装完再卸载镜像反而变大、为什么 docker diff 冒出一堆陌生路径、为什么挂了卷的目录在容器里删除后宿主文件依然健在------这些都需要下沉到 upperdir 与 mountinfo 才能回答。

本文分享一套可直接上手排查的方案:四目录叠加原理拆解 + 三态 diff 实测 + 三类挂载语义对照,所有命令均可在任意 Linux 主机复现。

一、容器一删数据就蒸发:写入落在最顶层

想弄清数据去向,先看清镜像栈最上方那层随时会消失的可写层:

  • 镜像层被冻结 :FROM、RUN、COPY 产出的每一层在容器启动后成为 lower,进程只能读不能改
  • 容器层可写 :运行期的每次 echo、rm、日志追加都写进最顶端的可写层,它随容器生灭,从不回流进镜像
  • 卷是旁路通道:凡是被卷覆盖的路径,写入直接落到宿主目录,绕开可写层

核心结论: 分层存储里"数据不见了"通常不是丢了,而是落在了会被销毁的那一层。

二、每层只记差异:diff 指令的物化结果

镜像之所以能被多个容器共享,靠的是每一层都只保存与下层不同的部分:

层序 产生方式 该层实际保存的内容 是否可写
第 1 层 FROM alpine:3.19 约 7 MB 的精简 rootfs 否
第 2 层 RUN apk add curl curl 相关新文件与配置改动 否
第 3 层 COPY app.jar /app/ app.jar 及目录元数据 否
顶层 容器运行期写入 与下方各层不同的部分 是
  • 层内容即差异集合:同名文件被新层覆盖后,旧版本仍完整保留在下层,这就是镜像"删不干净"的根源
  • 层数越碎开销越大 :每层一条元数据,RUN 拆得过细会让 inode 与元数据量明显上升

核心结论: 镜像体积取决于各层差异之和,删除文件只是追加遮蔽标记,并不会让层变小。

三、OverlayFS 四目录:lower/upper/work 分工明确

把视角切到内核,一次容器启动其实只是给 OverlayFS 喂了四类目录:

  • lowerdir 可多层:按从左到右的优先级排列,靠左的层遮住右侧同名文件
  • upperdir 唯一:容器唯一的可写目录,所有修改都发生在这里
  • workdir 同分区:OverlayFS 的内部中转站,必须与 upperdir 位于同一文件系统

下面这段命令读取当前 shell 自身看到的 overlay 挂载参数,适用于任意 Linux 发行版:

bash 复制代码
grep -w overlay /proc/self/mountinfo | tr ' ' '\n' | grep -E 'lowerdir|upperdir|workdir'

四目录就位 → 内核合成 merged 视图 → 容器进程看到统一的根文件系统

四、copy_up 与 whiteout:写时复制两步走

一次看似简单的覆盖写,在内核里其实被拆成了两个动作:

  • copy_up 全量拷贝:首次修改 lower 层文件时,内核把整个文件复制到 upperdir 再改,几 GB 的大文件也会被完整复制一次
  • whiteout 只做遮蔽 :删除不碰 lower 的原始数据,只在 upperdir 放一个字符设备文件,命名规则是 .wh. 加原文件名

下面的脚本演示删除镜像自带文件后,upperdir 里留下的究竟是什么:

bash 复制代码
cid=$(docker run -d alpine sleep 600)
docker exec $cid sh -c 'rm -f /etc/hostname'
upper=$(docker inspect -f '{{.GraphDriver.Data.UpperDir}}' "$cid")
ls -l "$upper/etc"      # 会看到形如 c--------- .wh.hostname 的字符设备
docker rm -f "$cid"

问题: 删除让上层看起来干净了。治理: 底层字节依然在,只有重构镜像并压平各层才能真正瘦身。

五、docker diff 三态视图:A/C/D 各自代表什么

想在不登进容器的前提下看清可写层,docker diff 是最省事的观测面:

  • A 表示新增:upperdir 里出现了 lower 层没有的路径
  • C 表示改动:文件内容或属主、权限等元数据与下层不同
  • D 表示删除:该路径被 whiteout 遮蔽,merged 视图里已经看不到

跑一遍下面的脚本即可同时看到三种状态:

bash 复制代码
cid=$(docker run -d alpine sleep 600)
docker exec $cid sh -c 'echo hi > /tmp/x; rm -f /etc/passwd; chmod 777 /tmp'
docker diff "$cid"
docker rm -f "$cid"

读 diff → 定位可写层膨胀点 → 决定迁卷还是重构镜像

六、可写层是易失空间:体积与性能的双重代价

可写层常被当成临时仓库,但它的真实成本比多数人预估的高:

  • 膨胀无预警且无留存 :apt install、日志落盘都堆进可写层,容器销毁时全部丢失,docker system df 里也看不到回收希望
  • copy_up 有 IO 放大:首次改写大文件会触发一次整文件读写,数据库数据目录放在这里既慢又危险

核心结论: 可写层只适合放运行期可重建的产物,任何需要留存或高频随机写的数据都该交给卷。

七、volume/bind/tmpfs:三种挂载的语义边界

Docker 对外只提供三种挂载,选错类型是数据事故的头号来源:

挂载类型 数据存放位置 容器删除后 典型场景
named volume /var/lib/docker/volumes/ 保留 数据库数据、随镜像迁移
bind mount 宿主任意指定路径 保留 挂配置文件、代码热更新
tmpfs 内存 立即消失 密钥、临时交换文件
  • 生命周期不同:只有 tmpfs 是易失的,named volume 与 bind 都活在宿主侧
  • 可见性不同:bind mount 直接暴露宿主目录结构,权限与安全上下文要单独处理

核心结论: 先问"这份数据该归谁管",再选挂载类型,顺序反了必然出事。

八、mountinfo 解剖:看清卷盖住了哪一层

判断某个目录到底归谁,最硬的证据是内核挂载表,而不是文档:

bash 复制代码
cid=$(docker run -d -v demo-vol:/data alpine sleep 600)
pid=$(docker inspect -f '{{.State.Pid}}' "$cid")
grep -E ' /data | / ' "/proc/$pid/mountinfo"
docker rm -f "$cid"
  • 独立挂载行 :/data 会出现自己的一行,来源是宿主卷目录,与容器根的 overlay 行毫无关系
  • 遮蔽即挂载:卷覆盖的路径在 merged 视图里被整块替换,底层镜像层的同名文件完全不可见

核心结论: mountinfo 里多一行,就多一层与镜像无关的独立空间。

九、层缓存失效规则:让 COPY 不再推翻整层

构建阶段的分层直接决定 CI 时长,缓存复用只认指令与上下文指纹:

  • 顺序敏感 :COPY package.json 与 COPY . 必须拆成两步,否则装依赖的层每次都会失效
  • 指纹敏感:被 COPY 的任一文件变化,该 COPY 层及其后续层全部重建

下面这份 Dockerfile 展示把依赖安装与业务代码拆开的标准写法,适用于 Node 与 JVM 类项目:

dockerfile 复制代码
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]

先 COPY 清单 → 装依赖 → 再 COPY 源码 → 依赖层永久复用

十、五类常见故障:定位到层就能对症下药

把前面的机制串成一张可直接对照的排错表:

现象 落在哪个环节 处置动作
容器重启数据没了 写进了可写层 迁移到 named volume
卸包后镜像反而变大 whiteout 只遮蔽不回收 多阶段构建或压平层
docker diff 持续膨胀 运行期日志缓存写到根目录 日志挂卷或改写 stdout
挂卷后文件被清空 空卷首次挂载覆盖镜像内容 写初始化脚本迁移数据
构建每次都全量执行 COPY 上下文指纹变化 拆分 COPY 顺序并加 .dockerignore
  • 先看现象归属 :容器层、卷、镜像层三条路径各有证据链,docker diff 与 mountinfo 能一次定性
  • 再动刀改造 :确认是可写层问题就迁卷,是镜像层问题就重构 Dockerfile,切勿盲目加 VOLUME 指令

核心结论: 排查顺序永远是先定位在哪一层,再决定改哪一层。

结语

分层存储的全部玄机都可以收敛到四个目录、两个内核动作和三类挂载上:lowerdir 决定你看到什么,upperdir 决定你改了什么,workdir 负责合成过程,卷则在容器根之外另开一块独立空间。把 docker diff 和 mountinfo 当成日常体检工具,数据蒸发、镜像肥大、构建变慢这三类问题都能在几分钟内定位到具体层。

掌握了这套视角,容器存储就不再是黑盒:能预判一次写入的落点,也能预估一次构建的缓存命中,运维与开发的分歧自然少了一大半。

看懂层,才真正看懂了容器。

相关推荐
honsor1 小时前
以太网温湿度传感器:RJ45直连机房的环境监控新方案
运维·网络·数据库·物联网·安全·云计算·github
zxcvb1531 小时前
规定备份信息的备份方式:从策略制定到自动化落地的实践思考
运维·oracle·自动化
goehou1 小时前
AI 应用上线实战:从本地脚本到 Docker 容器化(密钥、健康检查、镜像瘦身)
docker·ai·llm·部署·教程·容器化
吴声子夜歌1 小时前
Docker入门与实战——Docker数据管理
docker·容器
程序员老赵2 小时前
Docker 部署 TeslaMate:轻松搭建特斯拉车辆数据记录平台
docker·容器·开源
ZeroNews内网穿透2 小时前
内网穿透安全加固实践:通过 Geo 地理围栏阻挡境外扫描风险
运维·安全·devops
java_logo2 小时前
Docker 部署 Emby:轻松搭建家庭影音媒体服务器
服务器·docker·nas·emby·飞牛nas·群晖nas·轩辕镜像
柏慧通云报餐2 小时前
智慧食堂食材溯源数字化方案|采购 - 入库 - 餐桌全链路记录体系落地实践
大数据·运维·人工智能
河北清兮网络科技2 小时前
直播APP商用开发深度解析:为什么模板系统无法支撑规模化直播平台
运维·网络·人工智能·小程序·短剧app