2.1 镜像分层与UnionFS透视


2.1 镜像分层与 UnionFS 透视

摘要:镜像由若干只读层叠加而成,容器启动时在其上加盖一个可写层,由 overlay2 联合挂载成统一根目录。本节带你用 inspect 和宿主机目录把"层"摸到手上,讲透写时复制的读写路径与性能代价。

能力目标

  1. 用 docker inspect 与 docker history 查看镜像的层结构
  2. 说出 overlay2 的 lowerdir、upperdir、merged、workdir 四个目录的作用
  3. 解释读文件、改文件、删文件三种操作在 overlay2 中的真实路径
  4. 识别可写层膨胀导致的性能问题并知道规避方式

为什么需要分层

设想没有分层的年代:你基于 Ubuntu 基础镜像装了个 nginx,做成自己的镜像,整个根文件系统打包一份。同事又基于同一个 Ubuntu 装了 redis,再打包一份。两个镜像各自 200 MB,其中 190 MB 完全相同。十个团队、一百个镜像,仓库里堆满重复数据;每次拉取都全量传输。

分层的解法是把文件系统切成"层"这个复用单元:基础镜像一层、装 nginx 一层、改配置又一层。层与层可以任意组合,相同内容的层全局只存一份。两个镜像共享 Ubuntu 层,磁盘与网络开销立刻降一个数量级。这就是镜像层解决的核心问题——以层为单位做去重与增量分发

分层还带来第二个好处:构建缓存。Dockerfile 里每条会改文件系统的指令产生一层,下次构建时如果指令与输入没变,这一层直接复用。缓存逻辑放在 2.2 细讲。

把层摸到手上

先拉一个小镜像并查看它的层:

docker pull nginx:alpine docker inspect nginx:alpine --format '{{json .RootFS.Layers}}'

输出是一串层摘要(sha256):

["sha256:xd4a...e2f","sha256:9ce0...7ac","sha256:0dc3...b8e"]

三层。再看每层是怎么来的:

docker history nginx:alpine

输出从下往上读,每行对应一层或一层内的一条元数据操作:

IMAGE CREATED CREATED BY SIZE e4949... 3 weeks /bin/sh -c #(nop) CMD ["ngin 0B 5d0e9... 3 weeks /bin/sh -c #(nop) STOPSIGNAL 0B 9f1e2... 3 weeks /bin/sh -c set -x && addg 61.7MB ... bc4a2... 3 weeks /bin/sh -c #(nop) ADD file:... 7.34MB

注意两件事:其一,大部分行的 CREATED BY 是 /bin/sh -c 开头——RUN 指令本质就是在容器里跑一条 shell 命令,跑完把文件系统的差异定格为一层;其二,有些行 SIZE 是 0B,它们只改元数据(环境变量、暴露端口、启动命令),不产生文件层。

overlay2:层是怎么叠起来的

镜像层只是宿主机上的一堆目录(在 Docker 的存储区里,每层一个目录,用联合文件系统挂到一起)。Docker 当前的默认存储驱动是 overlay2,它基于 Linux 内核的 overlay 文件系统。核心概念四个目录:

  • lowerdir:所有只读镜像层,自上而下排列,上层遮蔽下层同名文件
  • upperdir:容器自己的可写层
  • workdir:overlay 内部处理复制、重命名用的工作目录
  • merged:把上述一切叠合后呈现给容器进程的根目录视图

跑一个容器,把它的 overlay 挂载信息抓出来:

docker run -d --name ov-demo nginx:alpine docker inspect ov-demo --format '{{json .GraphDriver.Data}}' | python -m json.tool

输出形如:

{ "LowerDir": "/var/lib/docker/overlay2/aa.../diff:/var/lib/docker/overlay2/bb.../diff", "MergedDir": "/var/lib/docker/overlay2/cc.../merged", "UpperDir": "/var/lib/docker/overlay2/cc.../diff", "WorkDir": "/var/lib/docker/overlay2/cc.../work" }

在宿主机上(需要 root)直接 ls 这个 MergedDir,你看到的就是容器眼中的根目录:bin、etc、usr 一应俱全。LowerDir 里冒号分隔的多个目录,就是镜像的多个只读层——层,从概念变成了可以 cd 进去的目录。

一次文件修改的完整路径:写时复制

三种典型操作在 overlay2 里的走法完全不同:

读文件:merged 视图自上而下查层,先在 upperdir 找,找不到按 lowerdir 顺序往下找,命中即返回。读操作没有额外代价,只有第一次跨层查找的一点开销。

改文件:这是重点。overlay 不允许直接改 lowerdir 里的文件,于是先把整个文件从镜像层复制到 upperdir,再修改 upperdir 里这份副本。这就是写时复制。如果一个 lower 层里有 600 MB 的大数据文件,容器里只是追加了一行日志,overlay 也会先把 600 MB 复制上来。日志类应用跑在容器里越跑越慢、磁盘占用暴涨,十有八九是踩了这个坑。

删文件:在 upperdir 里建一个"白障"字符设备标记该文件被删除,lower 层的文件本体纹丝不动。所以容器里删文件,磁盘占用反而可能增加——多了一个白障标记。

# 实验验证:容器内写文件,宿主机观察可写层 docker exec ov-demo sh -c "dd if=/dev/zero of=/tmp/big bs=1M count=50 2>/dev/null" sudo du -sh $(docker inspect ov-demo --format '{{.GraphDriver.Data.UpperDir}}')

输出大约 50 MB——容器里写的每个字节,都落在宿主机的 upperdir 里。

可写层不是放数据的地方

写时复制的存在决定了可写层的定位:只放临时变更,不放持久数据。三个理由:

  1. 性能:修改大文件触发整文件复制
  2. 生命周期:容器删除,upperdir 随之删除,数据不告而别
  3. 不可移植:可写层无法像镜像一样被打包分发

数据库文件、上传目录、日志归档,这些数据的正确去处是卷或绑定挂载——第 5 章存储层专门拆解。这里先立下规矩:凡是要活过容器生命周期的数据,一律不进可写层。

把镜像层想成一叠胶片,可写层是盖在最上面的草稿纸:画上去的东西随手就没,且擦除代价可能大得意外。

常见误读澄清

"层越多越慢"——不完全对。 层多主要影响的是首次挂载时的查找开销和镜像元数据规模,运行期的读路径影响很小。真正伤性能的是对大文件的小修改,而不是层数本身。不过层太多确实会拖慢构建、推送与拉取的元数据协商,控制在十几层内是健康的。

"镜像是压缩包"——错。 镜像在传输时是层压缩包的集合,在本地是 overlay2 目录加上一份层清单配置。这也是为什么同一份镜像在仓库里显示 25 MB,docker images 里却显示更大——前者是压缩后,后者是解压后磁盘占用。

本节要点回顾

  • 层是复用单元:相同层全局只存一份,分发与存储按层增量
  • RUN 产生文件层,元数据指令零字节:history 的 SIZE 列能直接看出每层多重
  • overlay2 四目录:lowerdir 只读层栈、upperdir 可写层、workdir 中转、merged 呈现给容器
  • 写时复制是双刃剑:读零代价,改大文件代价高昂
  • 可写层只放临时变更:持久数据必须交给卷,这是第 5 章的入场券

层怎么被制造出来?下一节拆 Dockerfile 与构建缓存。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U