Docker镜像分层与写时复制原理详解
核心思想概述
可以把 Docker 的镜像和容器想象成一本书和它的读书笔记。
- 镜像:就像一本印刷好的、不可更改的书。它由多个章节(分层)组成。
- 容器:就像你拿到这本书后,在上面盖了一张透明的临摹纸。你可以在临摹纸上做笔记、画重点,而不会破坏原书。
- 联合文件系统:就是让你能同时看到“原书的内容”和“透明纸上的笔记”的技术,形成一个完整的、可读写的视图。
- 写时复制:当你想修改原书上的某个字时,你不能直接改书,而是先把那个字描到你的透明纸上,然后在透明纸上修改。这个“描”的动作就是复制。
一、原理详解
1. 镜像分层
为什么分层?
为了极致地实现共享、复用和节省空间。
- 每一层都是文件系统的一组变化(
diff
),比如添加、修改或删除文件。每一层都有一个唯一的 ID。 - 层是只读的。一旦创建,就无法修改。这保证了镜像的不可变性。
- 基础层(Base Layer)通常是操作系统(如 Alpine, Ubuntu),上面的层是安装的软件、环境变量配置、应用代码等。
举个例子:构建一个 Node.js 应用镜像
假设你的 Dockerfile
如下:
FROM node:14-alpine # 层 1:基础操作系统 + Node.js 环境
WORKDIR /app # 层 2:创建并设置工作目录
COPY package*.json ./ # 层 3:复制 package.json 和 package-lock.json
RUN npm install # 层 4:安装依赖,会生成 node_modules 目录
COPY . . # 层 5:复制应用源代码
CMD ["node", "index.js"] # 层 6:设置容器启动命令
这个镜像最终由 6 个只读层堆叠而成。
分层的好处:
- 如果你只修改了源代码(第5层),Docker 在构建新镜像时,会复用前面4层的缓存,只重新生成第5层和第6层。这极大地加速了构建过程。
- 如果你的团队有10个服务都基于
node:14-alpine
,那么宿主机上只存储一份node:14-alpine
的层,所有镜像共享它。
2. 联合文件系统
它是如何把多层合并成一个统一视图的?
UFS 是一种文件系统服务,它能够将多个目录(层)透明地叠加在一起,形成一个单一、统一的文件系统视图。Docker 支持多种 UFS 实现,如 overlay2
(目前最常用)、aufs
等。
以 overlay2
为例,它有几个关键目录:
- lowerdir: 一个或多个只读层。对应镜像的层。
- upperdir: 可写层。对应容器的读写层。
- merged: 统一视图。将
lowerdir
和upperdir
合并后呈现给容器的最终目录。
当容器启动时,Docker 会:
- 将镜像的所有只读层作为
lowerdir
。 - 在宿主机上创建一个空目录作为
upperdir
。 - 将
lowerdir
和upperdir
合并,挂载到merged
目录。 - 容器进程看到的根文件系统就是这个
merged
目录。
3. 写时复制
这是发生在容器运行时的魔法。
当一个容器启动后(即在所有镜像层之上加了一个可写层),文件访问遵循以下规则:
-
读文件:
- 容器直接从
merged
视图读取文件。 - 如果文件在镜像层(
lowerdir
)中存在,且从未被修改过,容器就直接读取它。多个容器可以安全地共享读取同一个文件。
- 容器直接从
-
第一次修改文件:
- 关键步骤来了! 容器不能直接修改只读的镜像层。
- CoW 机制被触发:Docker 会将被修改的文件从镜像层(
lowerdir
)复制到容器的可写层(upperdir
)。 - 然后,所有后续的修改都作用于可写层中的这个副本。
- 从此,当容器再次读取这个文件时,UFS 会屏蔽镜像层中的旧文件,只呈现可写层中的新文件。
-
删除文件:
- 在 UFS 中,删除文件实际上是在可写层(
upperdir
)中创建一个特殊的“白障”文件,标记该文件在merged
视图中不可见。
- 在 UFS 中,删除文件实际上是在可写层(
二、实现机制(以 overlay2
为例)
假设我们有一个镜像,包含两层:
Lower1
: 包含文件/a.txt
,/dir/b.txt
Lower2
: 包含文件/a.txt
(覆盖了Lower1的版本),新增/c.txt
我们从这个镜像启动一个容器。
启动前的目录结构:
# 只读层 (lowerdir)
/lower1/a.txt (内容: "Hello from Lower1")
/lower1/dir/b.txt
/lower2/a.txt (内容: "Hello from Lower2") # 这个会覆盖lower1的a.txt
/lower2/c.txt# 可写层 (upperdir) - 初始为空
/upper/# 合并视图 (merged) - 呈现给容器
/merged/a.txt -> 显示 "Hello from Lower2" (来自lower2)
/merged/dir/b.txt -> 显示 lower1 的内容
/merged/c.txt -> 显示 lower2 的内容
容器内操作模拟:
-
读取
/c.txt
:- 容器进程请求读取
/c.txt
。 - UFS 在
merged
视图找到该文件,其数据来自lower2
。 - 无复制发生。
- 容器进程请求读取
-
修改
/a.txt
:- 容器试图修改
/a.txt
。 - CoW 触发!Docker 发现
a.txt
在lower2
中。 - Docker 将
a.txt
从lower2
复制到/upper/a.txt
。 - 容器对
/upper/a.txt
进行修改。 - 现在,
merged/a.txt
显示的是/upper/a.txt
的内容。lower2/a.txt
被“遮盖”了。
- 容器试图修改
此时目录结构:
# 只读层 (lowerdir) - 未变
/lower1/...
/lower2/...# 可写层 (upperdir) - 新增了复制的文件
/upper/a.txt (内容: "Modified by Container!")# 合并视图 (merged) - 已更新
/merged/a.txt -> 现在显示 "Modified by Container!" (来自upper)
/merged/dir/b.txt -> 显示 lower1 的内容
/merged/c.txt -> 显示 lower2 的内容
- 删除
/c.txt
:- 容器删除
/c.txt
。 - Docker 在可写层创建一个“白障”文件:
/upper/c.txt
(一个特殊字符设备文件,标记删除)。 - 现在,在
merged
视图里,/c.txt
就“消失”了。
- 容器删除
三、案例辅助介绍
案例一:高效部署多个Web应用
假设你要部署10个不同的Python Flask应用。
- 你为每个应用编写一个
Dockerfile
,都以FROM python:3.9-slim
开始。 - 在宿主机上,
python:3.9-slim
的所有层只存储一份。 - 当你运行这10个容器时,它们都共享
python:3.9-slim
的只读层。 - 每个容器只在需要修改系统文件或写入日志时,才使用自己的可写层。
- 结果:节省了巨量的磁盘空间,并且启动容器极快,因为不需要复制整个基础镜像。
案例二:快速迭代开发
你在开发一个应用:
- 首次构建镜像,所有层都被创建。
- 你修改了一行源代码,然后重新
docker build
。 - Docker 发现
COPY . .
这一层之前的缓存都有效,于是跳过npm install
等耗时操作,只复制新的代码并创建新层。 - 新镜像的构建时间可能从几分钟缩短到几秒钟。
- 你用新镜像启动容器,CoW 机制确保运行环境是全新的,但基础依赖没有被重复创建。
案例三:理解容器数据隔离
- 你从同一个镜像
my-app:latest
启动了两个容器:Container-A
和Container-B
。 - 两个容器都读取镜像中的
/app/config.json
。它们读的是同一份物理文件。 - 现在,
Container-A
修改了/app/config.json
。- CoW 触发,
/app/config.json
被复制到Container-A
的可写层并被修改。 Container-B
对此一无所知,它读取的仍然是镜像层中的原始文件。
- CoW 触发,
- 结果:两个容器实现了文件系统的完全隔离,互不影响。
总结
特性 | 原理 | 带来的好处 |
---|---|---|
镜像分层 | 将文件系统的变更记录为一组只读的、可复用的层。 | 高效存储:共享层节省空间。 快速构建:利用缓存加速镜像构建。 快速分发:只需拉取缺失的层。 |
联合文件系统 | 将多个只读层和一个可写层透明地合并成一个统一的文件系统视图。 | 统一的运行时视图:容器看到的是一个完整的文件系统。 |
写时复制 | 只有在需要修改文件时,才将文件从只读层复制到可写层。 | 快速启动:无需复制整个镜像。 资源高效:避免不必要的复制,节省I/O和空间。 隔离性:容器间的修改互不干扰。 |
这三者结合,共同构成了 Docker 高效、轻量的基石。理解它们,对于更好地使用和优化 Docker 至关重要。