为什么Docker镜像这么小?UnionFS分层存储的骚操作

24 阅读2分钟

开场暴击:一个让人懵逼的问题

你有没有遇到过这种情况:

# 第一次拉取 MySQL 8.0
docker pull mysql:8.0
# 下载大小:448MB

# 过几天又拉取 MySQL 5.7
docker pull mysql:5.7
# 下载大小:... 448MB?

等等,不是应该 448MB + 448MB = 896MB 吗?为什么硬盘才多了几百MB?

还有更魔幻的:

# 构建一个基于 Node 的应用
FROM node:16-alpine
# 基础镜像:118MB

# 加上你的代码和依赖
# 最终镜像:...120MB?

你把 node_modules(可能几百MB)打进去了,为什么镜像才涨了2MB?

今天咱们就来扒一扒 Docker 的这层"骚操作"——UnionFS 分层存储


第一部分:传统虚拟机的"胖"问题

在聊 Docker 之前,先看看传统虚拟机有多"重":

虚拟机A(CentOS + MySQL):5GB
虚拟机B(CentOS + Nginx):5GB
虚拟机C(CentOS + Redis):5GB

三个虚拟机 = 15GB?

为什么?因为每个虚拟机都有:

  • 完整的操作系统(3GB左右)
  • 重复的系统库(1GB左右)
  • 重复的基础工具(1GB左右)

这就像你每次想喝杯咖啡,都要从头建一座咖啡厂。 ( ` ω´ )


第二部分:Docker 的"乐高积木"思维

Docker 说:既然大家都要用 CentOS,为什么不共享一份?

于是就有了 分层存储(Layered Storage)

2.1 镜像就是一堆"层"的堆叠

看一下真实的镜像结构:

$ docker image inspect mysql:8.0 --format '{{json .RootFS.Layers}}' | jq

[
  "sha256:72e830a4dff5f0d5225cdc0a320e85ab1ce06ea5673acfe8d83a7645cbd0e9cf",
  "sha256:07b4a9068b6af337e8b8f1f1dae3dd14185b2c0003a9a1f0b6a24cd8d2eb6806",
  "sha256:cc644054967e516db4689b5282ee98e4bc4b11ea2255c9630309f559ab96562e",
  "sha256:e84fb818852626e89a09f5143dbc31fe7f0e0a6a24cd8d2eb68062b904337af4"
]

每一串 SHA256 就是一层,比如:

  • 第1层:基础操作系统(Debian)
  • 第2层:安装 ca-certificates
  • 第3层:安装 glibc 和相关库
  • 第4层:安装 MySQL 二进制文件

关键点:每一层都是只读的,且可以复用。 ( ̄▽ ̄)ゞ

2.2 容器 = 镜像 + 可写层

当你启动一个容器时:

镜像层(只读):
├─ Debian 基础系统
├─ ca-certificates
├─ glibc 库
└─ MySQL 程序

容器层(可写):
└─ 数据库文件、日志、临时文件

这就是为什么多个容器共享同一个镜像时,硬盘不会爆炸。 o( ̄▽ ̄)d


第三部分:UnionFS 的魔法——怎么把多层"叠"在一起

UnionFS(Union File System,联合文件系统) 是 Docker 分层存储的核心技术。

3.1 什么是 UnionFS?

用一个生活例子解释:

就像你在 Photoshop 里做多图层设计:

  • 图层1(底层):背景图
  • 图层2:人物剪影
  • 图层3:文字

你看到的"最终画面"是三个图层叠加的结果。

如果你修改"图层3"的文字,"图层1"和"图层2"不受影响。

如果你删除"图层3"的内容,下面的图层会"露出来"。

UnionFS 就是这个"图层管理器",它把多个目录"合并"成一个统一的文件系统。 ( ̄ω ̄)

3.2 UnionFS 的核心机制:写时复制(Copy-on-Write, CoW)

这是重点!敲黑板! ( ` ω´ )

当你从一个只读层读取文件时:

  • 直接读取原文件,不复制

当你修改一个只读层的文件时:

  1. 先复制到容器的可写层
  2. 再修改副本
  3. 原只读层的文件保持不变

举个实际例子:

# 启动一个 nginx 容器
docker run -d nginx

# 进入容器
docker exec -it <container_id> /bin/bash

# 查看配置文件(只读层)
cat /etc/nginx/nginx.conf
# 这是从镜像层读取的,不占额外空间

# 修改配置文件
echo "test" >> /etc/nginx/nginx.conf
# 触发 CoW:
# 1. 复制文件到可写层
# 2. 在可写层修改
# 3. 读取时优先返回可写层的版本

结果:你只修改了"变动部分",基础镜像层依然可以共享。 (^_^)b

3.3 不同镜像可以共享相同的层

还是那个 MySQL 的例子:

# 第一次拉取 mysql:8.0
docker pull mysql:8.0
# 下载:448MB
# 本地层:A → B → C → D

# 第二次拉取 mysql:5.7
docker pull mysql:5.7
# 下载:200MB(只下载了 E、F 层)
# 本地层:A → B → C → E → F
# A、B、C 层直接复用!

这就是为什么多个相似的镜像不会占用多倍空间。 ( ̄▽ ̄*)


第四部分:实战——看懂 Dockerfile 的每一行都是一层

写 Dockerfile 时,每个 RUN、COPY、ADD 指令都会创建新的一层

4.1 一个反面教材

FROM python:3.9

# ❌ 糟糕的写法:创建了无用的层
RUN apt-get update
RUN apt-get install -y gcc
RUN apt-get install -y make
RUN pip install flask
RUN pip install redis

问题:

  • 每个RUN都创建一层
  • 层多了会影响构建速度
  • 最终镜像也会变大(每层都有元数据开销)

4.2 正确姿势

FROM python:3.9

# ✅ 优秀写法:合并命令
RUN apt-get update && \
    apt-get install -y gcc make && \
    pip install flask redis && \
    apt-get clean && rm -rf /var/lib/apt/lists/*

好处:

  • 只创建一层
  • 清理了缓存文件(apt-get clean)
  • 镜像更小(*/ω*)

第五部分:Docker 的存储驱动——Overlay2 是目前的王

Docker 支持多种 UnionFS 实现:

  • overlay2:目前推荐,性能最好
  • aufs:老牌驱动,逐渐被 overlay2 取代
  • btrfs:功能强大但配置复杂
  • zfs:企业级,但资源消耗大
  • vfs:测试用,性能差

5.1 Overlay2 的工作原理

overlay2 使用三个目录:

/var/lib/docker/overlay2/<layer_id>/
├── lower/      # 只读的下层
├── upper/      # 可写的上层
└── merged/     # 合并后的视图(实际看到的)

文件访问规则:

  1. 读取文件时:

    • 优先从 upper/
    • 没有就去 lower/
  2. 写入文件时:

    • 总是写入 upper/
    • 触发 CoW 机制
  3. 删除文件时:

    • upper/ 创建一个"白out文件"(标记删除)
    • 实际的文件还在 lower/,但被"遮住"了

第六部分:实战技巧——如何构建更小的镜像

6.1 使用 Alpine 基础镜像

# ❌ Debian 基础镜像:180MB
FROM python:3.9

# ✅ Alpine 基础镜像:50MB
FROM python:3.9-alpine

Alpine 是什么?

  • 基于 musl libc 和 busybox
  • 极简的 Linux 发行版
  • 镜像只有 5MB 左右

注意: Alpine 可能会遇到兼容性问题(glibc vs musl),生产环境要测试!( ̄へ ̄)

6.2 多阶段构建(Multi-stage Build)

# 第一阶段:构建
FROM node:16 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# 第二阶段:运行
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html

原理:

  • 第一阶段把源码和依赖打包构建
  • 第二阶段只复制构建产物(dist)
  • 最终镜像不包含源码、node_modules、构建工具

效果: 镜像从 1GB 降到 20MB ( ` ω´ )

6.3 .dockerignore 文件

就像 .gitignore 一样,.dockerignore 可以排除不需要的文件:

# .dockerignore
node_modules
npm-debug.log
.git
.env
*.md

好处:

  • 构建上下文更小(传输更快)
  • 避免 COPY 时把垃圾文件打进镜像

第七部分:常见坑(别踩!)

7.1 坑1:Dockerfile 层数过多

# ❌ 反面教材
RUN apt-get update
RUN apt-get install git
RUN apt-get install curl
RUN apt-get install vim
# ... 50 层之后

问题:

  • Docker 对层数有限制(通常是127层)
  • 每层都有元数据开销
  • 构建和拉取都会变慢

解决方案: 合并 RUN 命令!( ̄▽ ̄)ゞ

7.2 坑2:不清理缓存

# ❌ 反面教材
RUN apt-get update
RUN apt-get install -y python3

# ✅ 正确姿势
RUN apt-get update && \
    apt-get install -y python3 && \
    rm -rf /var/lib/apt/lists/*

原因: apt-get update 会下载包列表到 /var/lib/apt/lists/,不清理的话这些缓存会被打进镜像。

7.3 坑3:不知道文件在哪一层

# 查看镜像的历史
docker history nginx:alpine

IMAGE          CREATED      CREATED BY                                      SIZE
a6bd2f12d57d   2 weeks ago  /bin/sh -c #(nop)  CMD ["nginx" "-g" "daemon…   0B
<missing>      2 weeks ago  /bin/sh -c #(nop)  EXPOSE 80                    0B
<missing>      2 weeks ago  /bin/sh -c #(nop)  STOPSIGNAL SIGQUIT           0B
<missing>      2 weeks ago  /bin/sh -c #(nop)  CMD ["nginx" "-v" "-g" "da…   0B
...

工具推荐: dive(可视化分析镜像层)

# 安装 dive
brew install dive  # macOS
choco install dive # Windows

# 分析镜像
dive nginx:alpine

第八部分:底层原理——Docker 是怎么实现 UnionFS 的?

Docker 使用 存储驱动(Storage Driver) 来对接不同的 UnionFS 实现。

8.1 存储驱动的作用

存储驱动负责:

  1. 管理镜像层和容器层
  2. 实现 CoW 机制
  3. 提供统一的文件系统视图

8.2 不同驱动的工作原理对比

驱动原理优点缺点
overlay2原生 Linux 内核支持性能最好、稳定需要 Linux 内核 4.0+
aufs早期联合文件系统兼容性好性能一般、逐渐淘汰
btrfsCopy-on-Write 文件系统功能强大、支持快照配置复杂、性能开销大
zfs企业级文件系统数据完整性高资源消耗大、学习曲线陡

8.3 查看 Docker 当前使用的驱动

$ docker info | grep "Storage Driver"

Storage Driver: overlay2

总结:Docker 镜像小的秘密

记住一句话:Docker 镜像不是"单个文件",而是"多层共享 + 写时复制"的产物。 ( ̄▽ ̄)/

关键要点:

  1. 分层存储:每个镜像由多层组成,层与层之间可以复用
  2. 写时复制:只在修改时才复制,读取时直接共享
  3. 联合挂载:UnionFS 把多层"合并"成一个统一的文件系统视图
  4. Overlay2 驱动:目前最推荐的实现,性能和稳定性都很好

实战建议:

  • ✅ 使用 Alpine 等小体积基础镜像
  • ✅ 合并 Dockerfile 的 RUN 命令
  • ✅ 用多阶段构建减小最终镜像
  • ✅ 配置 .dockerignore 排除无用文件
  • ✅ 用 docker historydive 分析镜像层

下次再看到 Docker 镜像只有几十MB,你就知道这背后是 UnionFS 的功劳了。( ` ω´ )


延伸阅读

如果你想深入了解:


你在项目里是怎么优化 Docker 镜像大小的?有没有遇到过 UnionFS 相关的坑?评论区聊聊,看看有没有更骚的操作。 (*/ω*)


Sources: