IT策士 10余年一线大厂经验专注 IT 思维、架构、职场进阶。我会在各个平台持续发布最新文章助你少走弯路。在第 2 篇中我们已经掌握了 Docker 的基本操作拉取镜像、启动容器、管理容器生命周期。先来快速回顾一下上篇的关键操作# 拉取 Nginx 镜像dockerpull 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 的形式组织数据。我们可以通过以下命令直接查看镜像在仓库中的完整标签和分层信息# 查看 Nginx 官方镜像有哪些可用标签此处展示前几个curl-shttps://hub.docker.com/v2/repositories/library/nginx/tags/?page_size5|\python3-mjson.tool|grepname:# 输出示例# name: 1.29-alpine3.21-perl,# name: 1.29-alpine3.21,# name: 1-alpine3.21-perl,# name: 1-alpine3.21,# name: latest,如果要查看具体某一个标签的详细分层结构# 查看 nginx:alpine 的镜像清单信息dockermanifest inspect nginx:alpine|python3-mjson.tool|head-n40输出示例{schemaVersion:2,mediaType:application/vnd.docker.distribution.manifest.v2json,config:{mediaType:application/vnd.docker.container.image.v1json,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 学习的基石。简单来说一张图直观理解┌──────────────────────────────────────────────┐ │ 运行中的容器 │ │ ┌──────────────────────────────────────┐ │ │ │ 容器层Container Layer │ │ │ │ 可读可写Read-Write │ │ │ ├──────────────────────────────────────┤ │ │ │ 镜像层3Image Layer3 │ │ │ │ 只读 │ │ │ ├──────────────────────────────────────┤ │ │ │ 镜像层2Image Layer2 │ │ │ │ 只读 │ │ │ ├──────────────────────────────────────┤ │ │ │ 镜像层1Image Layer1 │ │ │ │ 只读 │ │ │ └──────────────────────────────────────┘ │ └──────────────────────────────────────────────┘docker run的本质就是在只读镜像层之上添加一个全新的可写容器层。所有对容器的修改新建文件、修改配置、安装软件都发生在这一层不会影响底层只读镜像。这就是为什么同一个镜像可以启动无数个容器而互不干扰。二、分层结构的核心原理2.1 为什么采用分层结构Docker 镜像的分层设计带来了三大核心优势① 增量更新构建更快当你修改 Dockerfile 并重新构建镜像时Docker 只会重新构建那些发生改变的层及其之后的所有层未改变的层直接复用缓存。比如你只改了第 5 层的COPY指令前 4 层一秒都不用重建。# 第二次构建时可以看到缓存命中dockerbuild-tmyapp: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 的挂载过程# 1. 创建实验目录mkdir-p~/overlay-demo/{lower,upper,work,merged}# 2. 在 lower模拟只读镜像层中放入文件echoI am from the base image layer~/overlay-demo/lower/base.txtechoThis file exists in the lower layer~/overlay-demo/lower/shared.txt# 3. 在 upper模拟可写容器层中放入文件echoI am from the container writable layer~/overlay-demo/upper/container.txtechoThis file has been modified by the container~/overlay-demo/upper/shared.txt# 4. 执行联合挂载将 lower 和 upper 合并到 merged# 注意使用 $HOME 保证路径在 sudo 下正确展开sudomount-toverlay overlay\-olowerdir$HOME/overlay-demo/lower,upperdir$HOME/overlay-demo/upper,workdir$HOME/overlay-demo/work\$HOME/overlay-demo/merged# 5. 查看合并后的效果ls~/overlay-demo/merged/输出结果清晰地展示了合并逻辑base.txt ← 来自 lower 层 container.txt ← 来自 upper 层 shared.txt ← 上下层都有upper 层覆盖 lower 层验证覆盖规则cat~/overlay-demo/merged/shared.txt# 输出This file has been modified by the container# ↑ upper 层覆盖了 lower 层的同名文件从这个实验中可以总结出 OverlayFS 的合并规则如果一个文件只在下层存在它在合并视图中可见如果一个文件只在上层存在它在合并视图中也可见但如果一个文件在上下层同时存在合并视图会采用上层文件。OverlayFS 在 Docker 中的真实应用那么在 Docker 里这一套机制是怎么被用上的呢当你启动一个容器时Docker 会把所有只读镜像层作为lowerdir把容器专属的可写层作为upperdir然后联合挂载形成容器内部看到的完整文件系统。我们可以通过以下命令查看任意容器的挂载细节# 先启动一个测试容器dockerrun-d--nameinspect-demo nginx:alpine# 查看该容器的 OverlayFS 挂载信息dockerinspect inspect-demo\--format{{json .GraphDriver.Data}}|python3-mjson.tool输出示例{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-WriteCoW当容器尝试修改一个存在于只读镜像层的文件时Docker 不会直接修改原始文件而是先将该文件从只读层复制到容器的可写层然后在可写层中进行修改。原始文件保持不变其他容器仍然可以读取它。这个机制有两个好处第一保证了镜像的不可变性——原始镜像永远不会被修改第二极致高效——如果文件未被修改所有容器共享同一个底层副本不浪费任何额外存储。动手验证写时复制的文件级表现# 1. 基于 Alpine 启动一个测试容器dockerrun-d--namecow-demo alpine:3.19sleep3600# 2. 进入容器修改一个已存在的系统文件dockerexec-itcow-demosh# 在容器内执行echo# Modified by container/etc/hostscat/etc/hosts# 输出末尾会显示你追加的那一行exit# 3. 记录这个容器的 UpperDir 路径UPPER_DIR$(dockerinspect cow-demo--format{{.GraphDriver.Data.UpperDir}})echo$UPPER_DIR# 4. 在宿主机查看被复制的文件sudocat$UPPER_DIR/etc/hosts# 输出可以看到容器内修改后的完整内容# 注意原始镜像中的 /etc/hosts 并未改变你修改的/etc/hosts实际上被复制到了容器专属的可写层原始镜像层中的/etc/hosts毫发无损。这就是写时复制的直观效果。清理实验环境dockerrm-fcow-demo inspect-demosudoumount$HOME/overlay-demo/merged2/dev/null2.4 内容寻址存储Content-Addressable StorageDocker 镜像还有一个巧妙的设计内容寻址。每个镜像层都有一个唯一的 ID这个 ID 不是随机生成的而是根据该层内容的 SHA256 哈希值计算出来的。这意味着只要层的内容完全相同它们的 ID 就一定会相同。这种机制保证了镜像的唯一性和完整性可以密码学级别地验证Docker 可以通过比对哈希值判断层是否已存在避免重复存储和传输任何对镜像内容的篡改都会导致哈希值变化从而被立即发现。三、分层结构在磁盘上的真面目理论讲了这么多我们来亲眼看看分层结构在磁盘上是如何组织的。3.1 Overlay2 目录结构逐层拆解在 Docker 宿主机上镜像和容器的所有数据都存储在/var/lib/docker/下。使用 Overlay2 驱动时核心数据位于overlay2/子目录中。下面是典型 overlay2 目录结构/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/# 镜像配置元数据动手验证一下# 查看 overlay2 目录下的层数量sudols/var/lib/docker/overlay2/|grep-vl$\|layerdb\|imagedb|wc-l# 查看某个层的 diff 目录内容sudols/var/lib/docker/overlay2/|head-1|xargs-I{}sudols/var/lib/docker/overlay2/{}/diff/关键子目录功能速查3.2 镜像层 vs 容器层磁盘上的区别关键区别总结重要提醒绝对不要手动删除或修改 overlay2 目录中的内容这会破坏镜像和容器的完整性。如果磁盘空间不足应使用 Docker 原生命令清理# 查看磁盘使用情况dockersystemdf# 清理停止的容器、未使用的镜像和网络dockersystem 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指令合并为一个并在同一层中清理缓存避免缓存文件被永久保留在镜像层中# ❌ 反模式三个 RUN 创建三个层且缓存文件残留在中间层RUNapt-getupdate RUNapt-getinstall-ycurlvimRUNrm-rf/var/lib/apt/lists/*# ✅ 优化一个 RUN 只创建一个层安装完成后立即清理RUNapt-getupdate\apt-getinstall-ycurlvim\rm-rf/var/lib/apt/lists/*这样可以将缓存文件在同一层内删除不会残留在最终镜像中。5.3 善用 .dockerignore在docker build之前Docker 会将整个构建上下文默认当前目录打包发送给 Docker Daemon。如果项目目录中有大量无关文件如.git、node_modules、本地测试数据不仅浪费传输时间还会因为上下文过大导致构建变慢。在项目根目录创建.dockerignore文件.git node_modules *.log __pycache__ *.pyc .env .DS_Store.dockerignore的作用类似于.gitignore它告诉 Docker 在打包构建上下文时排除这些文件和目录从而减少构建上下文的大小加快构建速度并避免意外将敏感文件复制到镜像中。六、镜像安全实践安全是镜像管理中不可忽视的一环。6.1 为什么镜像安全如此重要根据 Sysdig 2022 年的报告75% 的容器镜像存在高危或严重漏洞。一个被攻破的容器可能泄露敏感数据、提升访问权限甚至瘫痪整个系统。6.2 使用 docker scout 或 Trivy 进行漏洞扫描定期扫描镜像是发现安全漏洞的关键手段。推荐使用以下工具# 方式一使用 Docker ScoutDocker Desktop 内置dockerscout quickview nginx:alpine# 方式二使用 Trivy开源、轻量、无需 Docker Desktop# 安装 Trivy以 Ubuntu 为例sudoapt-getinstall-ywgetapt-transport-httpswget-qO- https://aquasecurity.github.io/trivy-repo/deb/public.key|sudoapt-keyadd-echodeb https://aquasecurity.github.io/trivy-repo/deb$(lsb_release-sc)main|sudotee/etc/apt/sources.list.d/trivy.listsudoapt-getupdatesudoapt-getinstall-ytrivy# 扫描镜像trivy image nginx:alpine# 输出会列出发现的 CVE 漏洞按严重程度CRITICAL/HIGH/MEDIUM/LOW分类在生产环境中建议将漏洞扫描集成到 CI/CD 流水线中并设置质量门禁——例如存在CRITICAL或HIGH级别漏洞的镜像不允许推送到生产仓库。6.3 镜像版本固定避免使用latest标签因为它会随着 Docker Hub 上的更新而指向不同版本可能导致不可预期的行为。生产环境应使用明确的版本号# ❌ 不推荐dockerpull nginx:latest# ✅ 推荐dockerpull nginx:1.25.4-alpine七、系列贯穿项目Flask Redis 计数器镜像探秘还记得第 2 篇中启动的 Flask Redis 计数器应用吗让我们看看这个 Flask 应用的镜像分层结构为后续编写 Dockerfile 做好铺垫。# 拉取 Python 基础镜像dockerpull python:3.12-alpine# 查看镜像的分层历史dockerhistorypython:3.12-alpine输出部分IMAGE CREATED CREATED BY SIZE a04c2f8c...2weeks ago CMD[python3]0Bmissing2weeks ago RUN /bin/sh-cset-eux;...35.5MBmissing2weeks ago ENVPYTHON_VERSION3.12.8 0Bmissing2weeks ago /bin/sh-capkadd--no-cache python360.2MBmissing2weeks ago /bin/sh-capkadd--no-cache ca-certificates 620kBmissing2weeks ago /bin/sh-c#(nop) ADD file:... in / 7.38MB每一行就是一个镜像层CREATED BY列展示了该层对应的 Dockerfile 指令或基础镜像构建指令SIZE列是该层相对于前一层的增量大小。注意最底层的 Alpine 基础镜像只有 7.38MBPython 解释器及相关依赖占了约 60MB。Docker 的分层缓存机制可以减少构建时间镜像瘦身技巧可以压缩体积但前提是你要写出一个高质量的 Dockerfile。在第 4 篇中我将带你从零开始编写一个生产可用的 Dockerfile把 Flask 计数器应用“变成”标准化的镜像。八、本篇总结核心知识点回顾镜像本质一个只读模板由多个只读层叠加而成遵循“一次构建到处运行”原则。分层结构三大优势增量更新构建快、空间复用共享基础层、快速分发差分传输。UnionFSOverlay2将多个只读镜像层lowerdir和一个可写容器层upperdir联合挂载呈现统一文件系统视图。写时复制CoW容器修改文件时文件从只读层复制到可写层再修改保证镜像不可变且存储高效。内容寻址存储层 ID 由其内容的 SHA256 哈希决定保证唯一性和完整性。构建缓存指令未变则复用已缓存的层变化越少的指令应放在 Dockerfile 越前面。命令速查表下一篇文章——第 4 篇编写你的第一个 Dockerfile我们将从零开始把 Flask Redis 计数器应用写成一个标准化的 Dockerfile真正体验“一次构建到处运行”的威力。想了解更多还可以去各个平台搜索「IT策士」一起升级 IT 思维