开场暴击:一个让人懵逼的问题
你有没有遇到过这种情况:
# 第一次拉取 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)
这是重点!敲黑板! ( ` ω´ )
当你从一个只读层读取文件时:
- 直接读取原文件,不复制
当你修改一个只读层的文件时:
- 先复制到容器的可写层
- 再修改副本
- 原只读层的文件保持不变
举个实际例子:
# 启动一个 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/ # 合并后的视图(实际看到的)
文件访问规则:
-
读取文件时:
- 优先从
upper/读 - 没有就去
lower/读
- 优先从
-
写入文件时:
- 总是写入
upper/ - 触发 CoW 机制
- 总是写入
-
删除文件时:
- 在
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 存储驱动的作用
存储驱动负责:
- 管理镜像层和容器层
- 实现 CoW 机制
- 提供统一的文件系统视图
8.2 不同驱动的工作原理对比
| 驱动 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| overlay2 | 原生 Linux 内核支持 | 性能最好、稳定 | 需要 Linux 内核 4.0+ |
| aufs | 早期联合文件系统 | 兼容性好 | 性能一般、逐渐淘汰 |
| btrfs | Copy-on-Write 文件系统 | 功能强大、支持快照 | 配置复杂、性能开销大 |
| zfs | 企业级文件系统 | 数据完整性高 | 资源消耗大、学习曲线陡 |
8.3 查看 Docker 当前使用的驱动
$ docker info | grep "Storage Driver"
Storage Driver: overlay2
总结:Docker 镜像小的秘密
记住一句话:Docker 镜像不是"单个文件",而是"多层共享 + 写时复制"的产物。 ( ̄▽ ̄)/
关键要点:
- 分层存储:每个镜像由多层组成,层与层之间可以复用
- 写时复制:只在修改时才复制,读取时直接共享
- 联合挂载:UnionFS 把多层"合并"成一个统一的文件系统视图
- Overlay2 驱动:目前最推荐的实现,性能和稳定性都很好
实战建议:
- ✅ 使用 Alpine 等小体积基础镜像
- ✅ 合并 Dockerfile 的 RUN 命令
- ✅ 用多阶段构建减小最终镜像
- ✅ 配置 .dockerignore 排除无用文件
- ✅ 用
docker history或dive分析镜像层
下次再看到 Docker 镜像只有几十MB,你就知道这背后是 UnionFS 的功劳了。( ` ω´ )
延伸阅读
如果你想深入了解:
- Docker 官方文档 - 存储驱动:docs.docker.com/storage/sto…
- OverlayFS 官方文档:www.kernel.org/doc/Documen…
- dive 工具:github.com/wagoodman/d…
你在项目里是怎么优化 Docker 镜像大小的?有没有遇到过 UnionFS 相关的坑?评论区聊聊,看看有没有更骚的操作。 (*/ω*)
Sources: