很多 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 当成日常体检工具,数据蒸发、镜像肥大、构建变慢这三类问题都能在几分钟内定位到具体层。
掌握了这套视角,容器存储就不再是黑盒:能预判一次写入的落点,也能预估一次构建的缓存命中,运维与开发的分歧自然少了一大半。
看懂层,才真正看懂了容器。