第3章:Docker镜像的结构:分层文件系统;UnionFS与OverlayFS原理归纳总结

3.1 镜像不是“一个”文件,而是“一堆”层

好,咱们直接切入正题。很多新手就觉得Docker镜像就是一个大压缩包,拉下来解压就完事了。其实不是这样的。镜像的本质,是一个分层文件系统。

每一层,都代表Dockerfile中的一条指令。每一层,只保存和上一层相比“有变化”的那部分文件。这就是写时复制的核心理念。

我个人习惯打个比方:就像你写代码,每次commit只保存变更的代码行,而不是整个项目重新复制一遍。简直是天才的设计。

关键点:镜像层是只读的,永远不会被修改。容器运行时会在这堆只读层之上,再叠一个可写层。

举个例子,你写一个Dockerfile:

FROM ubuntu:22.04
RUN apt-get update && apt-get install -y python3
COPY app.py /app/
CMD ["python3", "/app/app.py"]

这个镜像拉下来就是4层:基础层、apt安装层、COPY层、CMD元数据层。每一层都有独立的哈希值。你想想看,如果多人共享同一个基础ubuntu层,磁盘占用就大大降低了。

问题来了——这些层彼此独立,怎么合成一个完整的文件系统给容器用?

答案就是联合挂载。

3.2 UnionFS:把碎片拼成完整视图

UnionFS是一个笼统的概念。它的核心思路很简单:将多个不同目录(或文件系统)联合挂载到同一个挂载点下,对外暴露一个统一的视图。

它解决的痛点很直接:一个镜像这么多层,每个层都是一个目录。用户需要看到的却是完整的/usr、/var、/etc等。那就把多个层叠起来,同路径的文件,上层覆盖下层。

我用表格帮你梳理一下UnionFS和后来者OverlayFS的区别:

特性 UnionFS(早期Docker) OverlayFS(主流方案)
合并层数 理论上不限,但性能随层数增加明显下降 固定2层(lowerdir + upperdir),性能更稳定
写时复制策略 每次写操作都从下层复制到上层 首次写时复制,后续直接处理
删除文件处理 whiteout文件机制 更简洁的whiteout+opaque目录
页缓存利用 较低,跨层共享命中率差 较高,能共享page cache

说白了,OverlayFS就是为了替代UnionFS而生的。我在项目中遇到过,早期使用Ubuntu 14.04跑Docker时,镜像层数一多(比如超过42层),性能就开始有明显损耗。OverlayFS出来以后,这个问题才被彻底根治。

3.3 OverlayFS的原理拆解

OverlayFS是目前Docker默认的存储驱动(在较新版本中叫overlay2)。咱们就把它的原理彻底整明白。

OverlayFS只使用两个目录:lowerdir(低层,只读)和upperdir(高层,可写)。最终用户看到的完整视图,就是merged目录。

具体行为很简单:

  • 当进程读取文件时,先看看upperdir里有没有;如果没有,才去lowerdir里找。说白了,上层优先。
  • 当进程写文件时,触发写时复制(Copy on Write, CoW):先把lowerdir里的文件复制到upperdir,然后再修改。这个复制只发生一次。
  • 当进程删除文件时,OverlayFS不会真的删除下层文件,而是往upperdir里放一个whiteout(字符设备文件,0:0),表示该文件被“屏蔽”了。

嗯,这里要注意:写时复制对于大文件的影响比较明显。比如一个数据库的页文件或者日志文件体积巨大,第一次写入时复制可能要花不少时间。

我做一个简单操作演示。假设你的宿主系统支持OverlayFS:

mkdir lower upper work merged

# lower层放几个测试文件
echo "我是lower层文件" > lower/lower_file.txt
echo "同名文件-lower版本" > lower/same_name.txt

# upper层放同名文件(用来验证覆盖)
echo "同名文件-upper版本" > upper/same_name.txt

# 挂载
mount -t overlay overlay -o lowerdir=lower,upperdir=upper,workdir=work merged

# 查看merged目录
cat merged/same_name.txt
# 输出:同名文件-upper版本(上层优先级高)
cat merged/lower_file.txt
# 输出:我是lower层文件(lower层可见)

你想想看,这个机制就跟Docker镜像叠加、容器层可写的行为一模一样。

避坑:我曾经在调试一个镜像缓存失效问题时,误以为docker commit会像mount --bind一样直接产生引用关系。后来发现它新建一层,会打破原有的OverlayFS缓存策略。记得大镜像commit后务必清理历史层,否则占磁盘没商量。

3.4 Docker如何利用OverlayFS组织镜像和容器

Docker把镜像的每一层都存到/var/lib/docker/overlay2/下。每个子目录对应一个层ID。层ID就是镜像层摘要的短格式。

对于同一个镜像的多个容器,它们共享底层的只读镜像层(lowerdir),只在上层放自己的容器层(upperdir)。

我画了一张流程图,帮你直观理解这个堆叠关系:

Docker OverlayFS 分层结构 Docker OverlayFS 分层挂载示意图 容器可写层 (upperdir) merged 最终文件系统视图 镜像只读层 (lowerdir): layer n (CMD 元数据层) layer n-1 (COPY app.py 层) layer 2 (apt install 层) layer 1 (Ubuntu 22.04 基础层) 用户/进程 关键说明 • 只读镜像层共享 • 容器层保存改动 • upperdir 覆盖 lowerdir • 首次写入触发 CoW

这张图你看懂就行了。每个容器启动时,Docker都会创建这样一个overlay挂载。容器内看到的所有文件,都是lowerdir + upperdir合并后的结果。

3.5 实战中我踩过的几个坑

  1. 只读层文件被误删:在容器里如果用rm删除了一个来自镜像的文件,upperdir里会出现一个whiteout文件。容器提交后,镜像里这个文件就消失了。曾经有人因为这个把生产环境搞坏了——镜像里包着/etc/ssl/certs的一个证书被删了。
  2. 大文件CoW性能:如果你运行一个容器,镜像层包含一个1GB的日志文件,容器内修改这个文件时,OverlayFS会先把整个文件复制到upperdir。这个过程会占用双倍磁盘空间,而且耗费大量时间。我建议,大文件尽量不要写在镜像层内,用挂载卷来管理。
  3. docker commit后层累积:频繁docker commit会导致镜像层数爆炸。OverlayFS本身对层数敏感,但如果你commit次数太多,最终推送时会非常慢。我个人的习惯是,每隔一段时间重构一次Dockerfile,避免commit过多。

特别提醒:OverlayFS的页缓存是共享的。多个容器如果使用同一个镜像层(lowerdir),它们对这个层的文件读取是可以命中同一块物理内存的。这是容器密度高的关键原因之一。但,如果你修改了lowerdir里的文件内容(比如在容器里修改了基础镜像的库),那这块page cache就失效了。所以不要轻易在容器内修改系统级别的库文件。

3.6 验证你的宿主机用的是overlay2

在讲完UnionFS和OverlayFS原理之后,我觉得你应该立刻去检查一下实际环境:

docker info | grep "Storage Driver"
# 期望输出:
# Storage Driver: overlay2

如果不是overlay2,我建议你迁移一下。现在的主流Linux内核(4.x以后)都已经原生支持。如果你用的是CentOS 7以上的系统,内核默认支持。如果你还在用aufs保存的历史版本——赶紧升级吧。

OverlayFS带给Docker的收益太大了:更快的启动速度、更少的磁盘占用、更好的IO性能。理解了它的原理,你才能真正懂得为什么Docker容器启动几秒钟就能跑起来,为什么同样的镜像可以几十上百个容器共享。

最后送你一句话:镜像分层,核心是差异;文件系统合并,核心是上层优先。记牢这两个点,以后讲Dockerfile优化、镜像瘦身、多阶段构建,你都能很快理解。