摘要:镜像由若干只读层叠加而成,容器启动时在其上加盖一个可写层,由 overlay2 联合挂载成统一根目录。本节带你用 inspect 和宿主机目录把"层"摸到手上,讲透写时复制的读写路径与性能代价。
设想没有分层的年代:你基于 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,它们只改元数据(环境变量、暴露端口、启动命令),不产生文件层。
镜像层只是宿主机上的一堆目录(在 Docker 的存储区里,每层一个目录,用联合文件系统挂到一起)。Docker 当前的默认存储驱动是 overlay2,它基于 Linux 内核的 overlay 文件系统。核心概念四个目录:
跑一个容器,把它的 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 里。
写时复制的存在决定了可写层的定位:只放临时变更,不放持久数据。三个理由:
数据库文件、上传目录、日志归档,这些数据的正确去处是卷或绑定挂载——第 5 章存储层专门拆解。这里先立下规矩:凡是要活过容器生命周期的数据,一律不进可写层。
把镜像层想成一叠胶片,可写层是盖在最上面的草稿纸:画上去的东西随手就没,且擦除代价可能大得意外。
"层越多越慢"——不完全对。 层多主要影响的是首次挂载时的查找开销和镜像元数据规模,运行期的读路径影响很小。真正伤性能的是对大文件的小修改,而不是层数本身。不过层太多确实会拖慢构建、推送与拉取的元数据协商,控制在十几层内是健康的。
"镜像是压缩包"——错。 镜像在传输时是层压缩包的集合,在本地是 overlay2 目录加上一份层清单配置。这也是为什么同一份镜像在仓库里显示 25 MB,docker images 里却显示更大——前者是压缩后,后者是解压后磁盘占用。
层怎么被制造出来?下一节拆 Dockerfile 与构建缓存。