6.2 镜像与分层、写时复制
一句话
镜像是 “一组只读层 + 一份配置” 的打包,容器的可写层只在第一次写时才真的拷贝(copy-up);启动快是因为 “不必解压整个镜像”,镜像胖是因为 “层与层之间重复”,两个现象都来自同一套分层规则。
镜像的解剖:三个构件
| 构件 | 里面是什么 | 可变性 |
|---|---|---|
| Manifest | 指向 config 与各层 digest 的清单(多架构时是一份 index) | 不可变,digest = 内容的 SHA256 |
| Config | 环境变量、启动命令、工作目录、标签、各层的 diff_id | 不可变 |
| Layers | 每层是相对父层的文件差异(tar + 压缩) | 不可变,可被多镜像共享 |
tag(如 app:1.4.2)只是一张可变的 “别名表”,指向某个 digest。理解这一点,才能理解为什么 “同一个 tag 在两个节点上拉到的镜像可能不同”(可变 tag 被覆盖),以及为什么生产要 pin digest 而不是 pin tag。
分层:只读、遮蔽、whiteout
- 每层只读,容器启动时按顺序叠成一份联合视图(rootfs)。
- 同名文件上层遮蔽下层:下层文件仍在(占空间),只是看不见。
- 删除也是加一层:在 OCI 镜像里,删除记录为 whiteout(
.wh.<name>文件),opaque 目录用.wh..wh..opq表示。
由此得到第一个反直觉结论:在 Dockerfile 里删文件不会让镜像变小。RUN rm -rf /tmp/pkg 只在上层留了一个 whiteout,下层那 300MB 还在。要真的变小,只能在同一层内产生又删除,或用多阶段构建把中间产物留在 builder 阶段(见 6.4 Dockerfile 与构建优化)。
写时复制:第一次写之前,什么都没有
| 时刻 | 磁盘状态 | 说明 |
|---|---|---|
| 拉取完成 | 只读层若干,没有容器层 | 100 个容器共享同一份只读层 |
| 容器启动 | 加一个空的 upperdir | 启动成本 ≈ 挂载,不是解压 |
首次修改 /etc/nginx.conf | 从下层拷贝该文件到 upperdir,再改 | copy-up,粒度是整个文件 |
| 修改一个 200MB 的日志文件 | 先拷 200MB 到 upperdir,再改几行 | 大文件小改动代价极高 |
| 删除容器 | upperdir 丢弃,数据消失 | 这就是 “容器无状态” 的物理基础 |
overlayfs 的四个目录,用一句话记住:
| 目录 | 作用 |
|---|---|
lowerdir | 只读层(可多个,按顺序叠) |
upperdir | 容器的可写层 |
workdir | 内核内部用的临时目录(必须与 upperdir 同文件系统) |
merged | 呈现给进程的联合视图 |
🔑 copy-up 的粒度是整个文件,不是块。 所以 “把数据库数据文件放在容器层” 是双重错误:数据会随容器消失,而且每次写入都可能拷一份大文件。数据必须走卷,见 6.7 容器存储与网络。
存储与内存:共享的是什么
| 资源 | 是否共享 | 影响 |
|---|---|---|
| 只读层(磁盘) | 共享(同节点同镜像只存一份) | 节点上跑 50 个同镜像容器,只占一份层空间 |
| page cache | 共享(同一份文件的缓存全局一份) | 多数情况下是好事:二进制与依赖只占一份内存 |
| 容器可写层 | 不共享 | 每个容器一份 upperdir,写多了占节点磁盘 |
| 进程与 namespace | 不共享 | 隔离由内核提供,见 6.3 namespace (隔离) 与 cgroups (限制) |
节点磁盘被写满是容器平台最常见的 “非故障型故障”:日志写 stdout(被 kubelet 落到节点盘)、临时文件写 /tmp(落在可写层)、大文件缓存写容器层。这些都属于 ephemeral storage,有配额上限,超了会被驱逐。
实测:删除真的不会让镜像变小
同一个 40MB 文件,两种写法(Docker Desktop 29.9.2 / linux-arm64):
| Dockerfile 写法 | 镜像大小 | 差 |
|---|---|---|
两层:RUN dd 生成 40MB + RUN rm -f | 55.6MB | — |
一层:RUN dd 生成 40MB && rm -f | 13.6MB | 省 42MB |
差值正好是那个 40MB 文件(加压缩与元数据开销)。两层写法里 rm 只产生了一个 whiteout,文件仍在上层被拉取与存储——这就是 “删了不变小” 的实证,也是多阶段构建存在的理由。
三个工程推论
- 启动时间 ≈ 拉取时间 + 挂载时间。冷启动慢,通常不是 “解压慢”,而是 “层多、层大、网络慢”。优化手段是共享基础层(同一 base 的镜像共享前几层)与减小层体积,而不是 “换个更快的运行时”。
- 镜像大小 = 各层之和,与 “最终文件系统看起来多大” 无关。因此优化要看每层的体积(
docker history逐层看),而不是看du -sh /。 - 构建缓存按层命中:缓存键 = 父层 digest + 指令文本。把易变的源码放在依赖安装之前,会让后面所有层每次重建(见 6.4 Dockerfile 与构建优化)。
反模式
| 反模式 | 后果 |
|---|---|
用 latest 或可变 tag 上线 | 两个节点拉到不同内容,故障无法复现 |
在 Dockerfile 里 rm 大文件想瘦身 | 镜像不变小(whiteout),只有同层内删除才有效 |
| 把数据库 / 上传文件写容器层 | 容器重建即丢数据,且 copy-up 放大 IO |
| 60 层的 “薄层” Dockerfile | 层多不一定慢,但每层元数据与拉取请求数增加;真正的问题是层大 |
| 日志直接写文件到容器内 | 节点磁盘被写满,触发驱逐 |
| 认为 page cache 各自独立 | 误判内存占用,容量规划算错 |
三条铁律
🔑 镜像不可变,tag 可变:生产 pin digest,tag 只给人看。
🔑 删除不减小镜像,只有 “不在层里出现” 才减小;瘦身靠多阶段构建与精简基础镜像。
🔑 可写层只放临时数据;任何要活过容器的东西,都走卷或对象存储。
自检
Q1 一个镜像 docker history 显示某层 300MB,下一层 5MB,最终 docker run 进去 du -sh / 只有 90MB。为什么?
▸ 答案:因为上层用 whiteout 遮蔽了下层的大目录(比如 RUN rm -rf /usr/share/docs),联合视图里看不见,但 300MB 仍在该层里被拉取与存储。镜像大小 = 各层之和 = 305MB。修法是把删除与安装放进同一层(如 apt-get install ... && rm -rf /var/lib/apt/lists/*),或用多阶段构建。
Q2 同一节点跑 50 个来自同一镜像的容器,磁盘和内存各占几份?
▸ 答案:只读层只占 1 份(共享),page cache 也共享(同一文件的页缓存全局唯一),所以内存通常只增加进程自身的堆栈与脏页。但 可写层是 50 份,且每个容器写入不同文件时会产生各自的 copy-up。所以 “容器密度高” 时先看可写层与 page cache 的账,而不是只看镜像大小。
Q3 为什么 “容器启动比 VM 快” 与 “镜像有 2GB” 可以同时成立?
▸ 答案:因为启动不需要展开整个镜像:只读层按需挂载(overlay 的联合视图),进程启动只触发它实际访问的文件。代价在首次拉取(要下载全部层)与首次读取(要走 overlay 查找)。所以 “启动快” 是挂载的功劳,不是 “镜像小” 的功劳;反过来,冷节点上的首次启动会被拉取时间拖慢。
Q4 一个容器一直在写 /var/log/app.log(写在容器层),跑一天后节点磁盘告警。根因是什么?
▸ 答案:/var/log 在容器可写层(overlay 的 upperdir),日志写满磁盘属于 ephemeral storage 超限。修法有三条:应用只写 stdout(由 kubelet 管理轮转)、把日志目录挂 emptyDir 并设 sizeLimit、或直接写远端日志。只调大节点磁盘是治标,因为 copy-up 与写入量仍会线性增长。
Q5 为什么 “把大文件挂成卷” 能同时解决数据丢失和 IO 放大?
▸ 答案:卷不经过 overlay 的 copy-up,写入直接落到卷的挂载点(块设备或网络存储),所以既不会因为 “改一个字节拷整份文件” 放大 IO,也不会随容器删除而消失。判断标准很简单:这份数据要不要活过容器?要,就挂卷;不要,就接受可写层会随容器消失。
跨篇相关:容器与虚拟机的边界差异见 6.1 容器与虚拟机的本质差异;层的构建顺序与多阶段构建见 6.4 Dockerfile 与构建优化;运行时可写层的落盘方式见 6.5 容器运行时 (containerd / runc);镜像漏洞与不可变制品见 6.6 镜像安全与扫描、54.1 不可变制品、54.2 镜像与仓库;存储卷与网络见 6.7 容器存储与网络;镜像缓存的构建侧优化见 52.1 构建与缓存。