IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在各个平台持续发布最新文章,助你少走弯路。
在第 2 篇中,我们已经掌握了 Docker 的基本操作:拉取镜像、启动容器、管理容器生命周期。先来快速回顾一下上篇的关键操作:
bash
# 拉取 Nginx 镜像
docker pull nginx:latest
# 输出:
# latest: Pulling from library/nginx
# 2d35ec5109b2: Pull complete ← 每一行都是一个"层(Layer)"
# 8b8e7c31c414: Pull complete
# f6a7c44ac56a: Pull complete
# f3af02bc16c2: Pull complete ← 最后一层
# Digest: sha256:42e917aaa... ← 镜像的完整哈希指纹
# Status: Downloaded newer image for nginx:latest
不知道你是否留意到,docker pull 的输出中,每一行 Pull complete 都对应了一个"层"。这些层共同构成了 Nginx 镜像。那么"层"到底是什么?为什么要分层?又是如何叠加起来的?这些都是我们今天要深入探讨的核心问题。
在正式开始镜像的深度探索之前,我们先来看看仓库中的镜像索引。Docker 仓库存储镜像时,会以清单(Manifest)和多层 blob 的形式组织数据。我们可以通过以下命令直接查看镜像在仓库中的完整标签和分层信息:
bash
# 查看 Nginx 官方镜像有哪些可用标签(此处展示前几个)
curl -s https://hub.docker.com/v2/repositories/library/nginx/tags/?page_size=5 | \
python3 -m json.tool | grep '"name":'
# 输出示例:
# "name": "1.29-alpine3.21-perl",
# "name": "1.29-alpine3.21",
# "name": "1-alpine3.21-perl",
# "name": "1-alpine3.21",
# "name": "latest",
如果要查看具体某一个标签的详细分层结构:
bash
# 查看 nginx:alpine 的镜像清单信息
docker manifest inspect nginx:alpine | python3 -m json.tool | head -n 40
输出示例:
bash
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"size": 8024,
"digest": "sha256:a04c2f8c..."
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 3652277,
"digest": "sha256:2d35ec5109b2..."
}
]
}
现在,让我们带着这些问题,一步步深入 Docker 镜像的内核。我会从理论出发,结合可动手的实操命令,帮助你彻底看清镜像分层、联合挂载和写时复制的底层原理。
一、镜像是什么?从"只读模板"说起
1.1 镜像的本质定义
Docker 镜像本质上是一个只读模板,包含运行容器所需的完整文件系统、环境依赖和配置参数。你可以把它理解为一个"应用的快照"------代码、运行时、系统库、环境变量,全部打包在一起,换一台机器也能原样运行。镜像设计遵循"一次构建,到处运行"原则,通过标准化封装实现应用与环境的解耦。
从实现层面看,Docker 镜像并非一个单一的大文件,而是由多个**只读层(Layers)叠加而成的"积木结构"。每个指令(如 RUN、COPY)都会生成一个独立的层,这些层通过联合文件系统(UnionFS)**组合成统一的文件系统视图。
这种分层设计不是炫技,而是 Docker 高效、灵活、可复用特性的根基。
1.2 镜像与容器的关系:模板与实例
理解"镜像-容器"的关系,是整个 Docker 学习的基石。简单来说:
一张图直观理解:
bash
┌──────────────────────────────────────────────┐
│ 运行中的容器 │
│ ┌──────────────────────────────────────┐ │
│ │ 容器层(Container Layer) │ │
│ │ 可读可写(Read-Write) │ │
│ ├──────────────────────────────────────┤ │
│ │ 镜像层 3(Image Layer 3) │ │
│ │ 只读 │ │
│ ├──────────────────────────────────────┤ │
│ │ 镜像层 2(Image Layer 2) │ │
│ │ 只读 │ │
│ ├──────────────────────────────────────┤ │
│ │ 镜像层 1(Image Layer 1) │ │
│ │ 只读 │ │
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
docker run 的本质,就是在只读镜像层之上,添加一个全新的可写容器层。所有对容器的修改(新建文件、修改配置、安装软件)都发生在这一层,不会影响底层只读镜像。这就是为什么同一个镜像可以启动无数个容器而互不干扰。
二、分层结构的核心原理
2.1 为什么采用分层结构?
Docker 镜像的分层设计,带来了三大核心优势:
① 增量更新,构建更快
当你修改 Dockerfile 并重新构建镜像时,Docker 只会重新构建那些发生改变的层及其之后的所有层,未改变的层直接复用缓存。比如你只改了第 5 层的 COPY 指令,前 4 层一秒都不用重建。
bash
# 第二次构建时可以看到缓存命中
docker build -t myapp:v2 .
# 输出关键行:
# Step 1/8 : FROM python:3.12-alpine
# ---> a04c2f8c... ← 基础镜像已在本地
# Step 2/8 : WORKDIR /app
# ---> Using cache ← 缓存命中!
# ---> b1c3d5e7... ← 直接复用
# Step 3/8 : COPY requirements.txt .
# ---> Using cache ← 缓存命中!
# ...
# Step 7/8 : COPY . . ← 源码变动,缓存失效
# ---> e8f9a0b1... ← 从这里开始重新构建
2.2 UnionFS(联合文件系统)全景解析
分层结构要落地,核心依赖是联合文件系统(UnionFS) 。Docker 支持多种存储驱动,目前在大多数 Linux 发行版上,Overlay2 是默认且推荐的驱动。
理解 UnionFS 的核心思想
UnionFS 的作用说起来其实很简单:把多个目录"叠"在一起,对外呈现为一个统一的目录。具体怎么叠呢?我们直接动手演示。
动手实验:手工模拟 OverlayFS 的挂载过程
bash
# 1. 创建实验目录
mkdir -p ~/overlay-demo/{lower,upper,work,merged}
# 2. 在 lower(模拟只读镜像层)中放入文件
echo "I am from the base image layer" > ~/overlay-demo/lower/base.txt
echo "This file exists in the lower layer" > ~/overlay-demo/lower/shared.txt
# 3. 在 upper(模拟可写容器层)中放入文件
echo "I am from the container writable layer" > ~/overlay-demo/upper/container.txt
echo "This file has been modified by the container" > ~/overlay-demo/upper/shared.txt
# 4. 执行联合挂载(将 lower 和 upper 合并到 merged)
# 注意:使用 $HOME 保证路径在 sudo 下正确展开
sudo mount -t overlay overlay \
-o lowerdir=$HOME/overlay-demo/lower,upperdir=$HOME/overlay-demo/upper,workdir=$HOME/overlay-demo/work \
$HOME/overlay-demo/merged
# 5. 查看合并后的效果
ls ~/overlay-demo/merged/
输出结果清晰地展示了合并逻辑:
bash
base.txt ← 来自 lower 层
container.txt ← 来自 upper 层
shared.txt ← 上下层都有,upper 层覆盖 lower 层
验证覆盖规则:
bash
cat ~/overlay-demo/merged/shared.txt
# 输出:This file has been modified by the container
# ↑ upper 层覆盖了 lower 层的同名文件
从这个实验中可以总结出 OverlayFS 的合并规则:如果一个文件只在下层存在,它在合并视图中可见;如果一个文件只在上层存在,它在合并视图中也可见;但如果一个文件在上下层同时存在,合并视图会采用上层文件。
OverlayFS 在 Docker 中的真实应用
那么在 Docker 里,这一套机制是怎么被用上的呢?当你启动一个容器时,Docker 会把所有只读镜像层作为 lowerdir,把容器专属的可写层作为 upperdir,然后联合挂载,形成容器内部看到的完整文件系统。我们可以通过以下命令查看任意容器的挂载细节:
bash
# 先启动一个测试容器
docker run -d --name inspect-demo nginx:alpine
# 查看该容器的 OverlayFS 挂载信息
docker inspect inspect-demo \
--format='{{json .GraphDriver.Data}}' | python3 -m json.tool
输出示例:
bash
{
"LowerDir": "/var/lib/docker/overlay2/abc123.../diff:/var/lib/docker/overlay2/def456.../diff",
"MergedDir": "/var/lib/docker/overlay2/xyz789.../merged",
"UpperDir": "/var/lib/docker/overlay2/xyz789.../diff",
"WorkDir": "/var/lib/docker/overlay2/xyz789.../work"
}
关键字段解读:
Overlay2 原生支持最多 128 层 lower 层的叠加,为 Docker 镜像的多层构建提供了充足的灵活性和性能保障。
2.3 写时复制(Copy-on-Write):高效隔离的核心机制
分层结构带来一个问题:容器需要修改文件时怎么办?底层镜像是只读的,不能直接在原文件上改。
Docker 的答案是写时复制(Copy-on-Write,CoW):当容器尝试修改一个存在于只读镜像层的文件时,Docker 不会直接修改原始文件,而是先将该文件从只读层复制到容器的可写层,然后在可写层中进行修改。原始文件保持不变,其他容器仍然可以读取它。
这个机制有两个好处:第一,保证了镜像的不可变性------原始镜像永远不会被修改;第二,极致高效------如果文件未被修改,所有容器共享同一个底层副本,不浪费任何额外存储。
动手验证:写时复制的文件级表现
bash
# 1. 基于 Alpine 启动一个测试容器
docker run -d --name cow-demo alpine:3.19 sleep 3600
# 2. 进入容器,修改一个已存在的系统文件
docker exec -it cow-demo sh
# 在容器内执行:
echo "# Modified by container" >> /etc/hosts
cat /etc/hosts
# 输出末尾会显示你追加的那一行
exit
# 3. 记录这个容器的 UpperDir 路径
UPPER_DIR=$(docker inspect cow-demo --format='{{.GraphDriver.Data.UpperDir}}')
echo $UPPER_DIR
# 4. 在宿主机查看被复制的文件
sudo cat $UPPER_DIR/etc/hosts
# 输出:可以看到容器内修改后的完整内容
# 注意:原始镜像中的 /etc/hosts 并未改变
你修改的 /etc/hosts 实际上被复制到了容器专属的可写层,原始镜像层中的 /etc/hosts 毫发无损。这就是写时复制的直观效果。
清理实验环境:
bash
docker rm -f cow-demo inspect-demo
sudo umount $HOME/overlay-demo/merged 2>/dev/null
2.4 内容寻址存储(Content-Addressable Storage)
Docker 镜像还有一个巧妙的设计:内容寻址。每个镜像层都有一个唯一的 ID,这个 ID 不是随机生成的,而是根据该层内容的 SHA256 哈希值计算出来的。这意味着:只要层的内容完全相同,它们的 ID 就一定会相同。这种机制保证了:镜像的唯一性和完整性可以密码学级别地验证;Docker 可以通过比对哈希值判断层是否已存在,避免重复存储和传输;任何对镜像内容的篡改都会导致哈希值变化,从而被立即发现。
三、分层结构在磁盘上的真面目
理论讲了这么多,我们来亲眼看看分层结构在磁盘上是如何组织的。
3.1 Overlay2 目录结构逐层拆解
在 Docker 宿主机上,镜像和容器的所有数据都存储在 /var/lib/docker/ 下。使用 Overlay2 驱动时,核心数据位于 overlay2/ 子目录中。
下面是典型 overlay2 目录结构:
bash
/var/lib/docker/overlay2/
├── l/ # 硬链接缓存目录(缩短长路径)
│ ├── A1B2C3... → ../abc.../diff/
│ └── D4E5F6... → ../def.../diff/
├── abc123.../ # 一个镜像层的哈希目录
│ ├── diff/ # 该层的实际文件数据(只读)
│ ├── link # 指向 l/ 目录中短链接的名称
│ └── lower # 记录父层信息(该层依赖的下层)
├── def456.../ # 另一个镜像层
│ └── diff/
├── xyz789.../ # 一个容器的可写层
│ ├── diff/ # 容器的文件变更(可读写)
│ ├── merged/ # 联合挂载后的完整视图(容器的根文件系统)
│ ├── work/ # OverlayFS 内部工作目录(用户不可见)
│ ├── link
│ └── lower # 指向该容器依赖的所有镜像层
├── layerdb/ # 镜像层元数据库(父层关系、diff_id等)
└── imagedb/ # 镜像配置元数据
动手验证一下:
bash
# 查看 overlay2 目录下的层数量
sudo ls /var/lib/docker/overlay2/ | grep -v "l$\|layerdb\|imagedb" | wc -l
# 查看某个层的 diff 目录内容
sudo ls /var/lib/docker/overlay2/ | head -1 | xargs -I {} sudo ls /var/lib/docker/overlay2/{}/diff/
关键子目录功能速查:
3.2 镜像层 vs 容器层:磁盘上的区别
关键区别总结:
重要提醒:绝对不要手动删除或修改 overlay2 目录中的内容,这会破坏镜像和容器的完整性。如果磁盘空间不足,应使用 Docker 原生命令清理:
bash
# 查看磁盘使用情况
docker system df
# 清理停止的容器、未使用的镜像和网络
docker system prune -a
四、构建缓存机制:分层结构带来的构建提速
理解了分层结构,Docker 构建镜像时的"缓存魔法"就很好理解了。
4.1 Docker 构建的缓存原理
Docker 构建镜像时,会按照 Dockerfile 中的指令顺序逐层执行并缓存。当重新构建镜像时,Docker 会对每条指令执行缓存查找:
-
缓存命中:如果指令及其输入(如被 COPY 的文件内容)都没有变化,Docker 直接从缓存中取出之前构建好的层,秒级复用。
-
缓存失效:如果某条指令或输入发生变化,Docker 会将该层及之后所有层的缓存标记为失效,从这一层开始全部重新构建。
这正是很多开发者初次构建镜像很慢,但之后修改一两行代码重新构建却非常快的原因。
4.2 利用缓存优化构建速度
掌握以下原则,可以充分利用缓存:
-
变化越少的指令放在越前面 。基础镜像几乎不变,放在第一行;依赖文件(如
requirements.txt)不常变,在复制源码之前先复制依赖文件并安装;源代码经常变,放在最后一行。这样,修改源码只会触发最后一层的重新构建,依赖安装层始终走缓存。 -
区分 COPY 的对象 。先
COPY requirements.txt .再RUN pip install,最后再COPY . .。这样只要依赖不变,前两步就是缓存。反面教材是一上来就COPY . .------代码改一个字,整个缓存链全断。 -
合并 RUN 指令减少层数。将多个关联的 RUN 指令合并为一个,减少不必要的中间层。
五、镜像优化实战
分层结构不仅带来构建效率的提升,也为镜像优化提供了理论基础。
5.1 精简基础镜像选型
基础镜像是所有层的"地基",选轻量版可以显著减小最终镜像体积。以下是一些常用基础镜像的体积对比:
注意:Alpine 使用 musl libc 而非 glibc,部分应用可能不兼容。选型时需要在体积和兼容性之间权衡。
5.2 Dockerfile 优化:合并 RUN 指令
每次 RUN 都会创建一个新的镜像层。最佳实践是将多个关联的 RUN 指令合并为一个,并在同一层中清理缓存,避免缓存文件被永久保留在镜像层中:
bash
# ❌ 反模式:三个 RUN 创建三个层,且缓存文件残留在中间层
RUN apt-get update
RUN apt-get install -y curl vim
RUN rm -rf /var/lib/apt/lists/*
# ✅ 优化:一个 RUN 只创建一个层,安装完成后立即清理
RUN apt-get update && \
apt-get install -y curl vim && \
rm -rf /var/lib/apt/lists/*
这样可以将缓存文件在同一层内删除,不会残留在最终镜像中。
5.3 善用 .dockerignore
在 docker build 之前,Docker 会将整个构建上下文(默认当前目录)打包发送给 Docker Daemon。如果项目目录中有大量无关文件(如 .git、node_modules、本地测试数据),不仅浪费传输时间,还会因为上下文过大导致构建变慢。
在项目根目录创建 .dockerignore 文件:
bash
.git
node_modules
*.log
__pycache__
*.pyc
.env
.DS_Store
.dockerignore 的作用类似于 .gitignore,它告诉 Docker 在打包构建上下文时排除这些文件和目录,从而减少构建上下文的大小,加快构建速度并避免意外将敏感文件复制到镜像中。
六、镜像安全实践
安全是镜像管理中不可忽视的一环。
6.1 为什么镜像安全如此重要?
根据 Sysdig 2022 年的报告,75% 的容器镜像存在高危或严重漏洞。一个被攻破的容器可能泄露敏感数据、提升访问权限,甚至瘫痪整个系统。
6.2 使用 docker scout 或 Trivy 进行漏洞扫描
定期扫描镜像是发现安全漏洞的关键手段。推荐使用以下工具:
bash
# 方式一:使用 Docker Scout(Docker Desktop 内置)
docker scout quickview nginx:alpine
# 方式二:使用 Trivy(开源、轻量、无需 Docker Desktop)
# 安装 Trivy(以 Ubuntu 为例)
sudo apt-get install -y wget apt-transport-https
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add -
echo "deb https://aquasecurity.github.io/trivy-repo/deb $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt-get update
sudo apt-get install -y trivy
# 扫描镜像
trivy image nginx:alpine
# 输出会列出发现的 CVE 漏洞,按严重程度(CRITICAL/HIGH/MEDIUM/LOW)分类
在生产环境中,建议将漏洞扫描集成到 CI/CD 流水线中,并设置质量门禁------例如,存在 CRITICAL 或 HIGH 级别漏洞的镜像不允许推送到生产仓库。
6.3 镜像版本固定
避免使用 latest 标签,因为它会随着 Docker Hub 上的更新而指向不同版本,可能导致不可预期的行为。生产环境应使用明确的版本号:
bash
# ❌ 不推荐
docker pull nginx:latest
# ✅ 推荐
docker pull nginx:1.25.4-alpine
七、系列贯穿项目:Flask + Redis 计数器镜像探秘
还记得第 2 篇中启动的 Flask + Redis 计数器应用吗?让我们看看这个 Flask 应用的镜像分层结构,为后续编写 Dockerfile 做好铺垫。
bash
# 拉取 Python 基础镜像
docker pull python:3.12-alpine
# 查看镜像的分层历史
docker history python:3.12-alpine
输出(部分):
bash
IMAGE CREATED CREATED BY SIZE
a04c2f8c... 2 weeks ago CMD ["python3"] 0B
<missing> 2 weeks ago RUN /bin/sh -c set -eux; ... 35.5MB
<missing> 2 weeks ago ENV PYTHON_VERSION=3.12.8 0B
<missing> 2 weeks ago /bin/sh -c apk add --no-cache python3 60.2MB
<missing> 2 weeks ago /bin/sh -c apk add --no-cache ca-certificates 620kB
<missing> 2 weeks ago /bin/sh -c #(nop) ADD file:... in / 7.38MB
每一行就是一个镜像层,CREATED BY 列展示了该层对应的 Dockerfile 指令(或基础镜像构建指令),SIZE 列是该层相对于前一层的增量大小。注意最底层的 Alpine 基础镜像只有 7.38MB,Python 解释器及相关依赖占了约 60MB。
Docker 的分层缓存机制可以减少构建时间,镜像瘦身技巧可以压缩体积,但前提是你要写出一个高质量的 Dockerfile。在第 4 篇中,我将带你从零开始编写一个生产可用的 Dockerfile,把 Flask 计数器应用"变成"标准化的镜像。
八、本篇总结
核心知识点回顾
-
镜像本质:一个只读模板,由多个只读层叠加而成,遵循"一次构建,到处运行"原则。
-
分层结构三大优势:增量更新(构建快)、空间复用(共享基础层)、快速分发(差分传输)。
-
UnionFS(Overlay2):将多个只读镜像层(lowerdir)和一个可写容器层(upperdir)联合挂载,呈现统一文件系统视图。
-
写时复制(CoW):容器修改文件时,文件从只读层复制到可写层再修改,保证镜像不可变且存储高效。
-
内容寻址存储:层 ID 由其内容的 SHA256 哈希决定,保证唯一性和完整性。
-
构建缓存:指令未变则复用已缓存的层,变化越少的指令应放在 Dockerfile 越前面。
命令速查表
下一篇文章------第 4 篇:编写你的第一个 Dockerfile,我们将从零开始,把 Flask + Redis 计数器应用写成一个标准化的 Dockerfile,真正体验"一次构建,到处运行"的威力。
想了解更多还可以去各个平台搜索「IT策士」,一起升级 IT 思维!