一、镜像的本质:分层文件系统与只读模板

聊容器镜像之前,我想先问你一个问题:一个运行中的容器,到底和它的镜像是什么关系?

很多人刚接触 Docker 时,会把镜像理解成一个「压缩包」——把应用和依赖打进去,run 起来就完事了。嗯,这个说法不算错,但太粗糙了。如果你只停留在这一步,后面遇到镜像加载慢、磁盘爆满、层冲突这些问题时,你会一头雾水。

我个人习惯把镜像比作一个「只读的食材包」。你想想看,超市里卖的冷冻披萨——饼底、酱料、芝士、香肠,一层一层叠好,封装起来。你买回家,烤箱一加热,就能吃。但你不会直接啃冷冻披萨对吧?容器也一样:镜像本身是静态的、只读的,真正跑起来的是基于它创建的容器实例。

核心定义:容器镜像是一个只读的、分层构建的文件系统模板,包含了运行一个应用所需的全部内容——代码、运行时、系统工具、库、配置。

1.1 从物理构成说起:分层文件系统

镜像最核心的设计,就是分层(Layer)。每一层代表一组文件变更——添加、修改或删除。这些层堆叠在一起,最终形成一个完整的根文件系统。

我记得刚接触 Docker 那会儿,看到 docker pull 时一堆 SHA256 的层 ID 在滚动,心里直犯嘀咕:这玩意儿到底是怎么拼起来的?后来我手动翻过 /var/lib/docker/overlay2/ 目录,才真正看明白。

来看一个典型的镜像层结构:

nginx:latest 镜像的层(从下往上)
┌─────────────────────────────────┐
│ Layer 6: 修改 nginx.conf        │  ← 可写容器层(运行后)
├─────────────────────────────────┤
│ Layer 5: COPY index.html        │  ← 镜像层
├─────────────────────────────────┤
│ Layer 4: RUN apt-get install    │  ← 镜像层
├─────────────────────────────────┤
│ Layer 3: 修改 /etc/apt/sources  │  ← 镜像层
├─────────────────────────────────┤
│ Layer 2: 添加 ca-certificates   │  ← 镜像层
├─────────────────────────────────┤
│ Layer 1: debian:bookworm 基础层 │  ← 基础镜像层
└─────────────────────────────────┘

每一层本质上是一个 diff(差异集)。Docker 使用 UnionFS(联合文件系统) 把这些层「合并」成一个统一的视图。你在容器里看到的 /usr、/etc、/var,其实是所有层叠加后的结果。

小提示:你可以用 docker history <镜像名> 查看一个镜像的所有层以及每层对应的构建命令。这是排查镜像体积问题的第一把钥匙。

1.2 为什么分层?—— 共享与复用

你可能会问:为什么不直接把所有文件打成一个 tarball?非要搞这么复杂?

原因很简单:复用。

假设你的服务器上跑了 10 个基于 debian:bookworm 的容器。如果每个镜像都完整打包一份 debian 根文件系统,磁盘占用直接爆炸。但分层之后,所有容器共享同一份基础层,只在上面叠加各自的应用层。

我在项目中遇到过这样一个场景:一个微服务集群有 20 多个服务,全都基于同一个定制化的 Java 基础镜像。基础层大概 400MB,应用层平均 50MB。如果不用分层,每个服务镜像都要 450MB,20 个就是 9GB。分层之后,基础层只存一份,实际占用只有 400MB + 20 × 50MB = 1.4GB。差距一目了然。

对比维度 分层镜像 扁平化镜像(单层)
磁盘复用 共享基础层,节省空间 每份镜像独立存储
传输效率 只拉取缺失的层 每次全量下载
构建缓存 层缓存,加速构建 无缓存,每次重做
更新粒度 只替换变更的层 必须整体替换

1.3 镜像 = 只读模板 + 容器 = 可写实例

这里有一个关键点,很多人一开始会搞混:镜像本身是只读的,你永远不能直接修改一个镜像。

当你执行 docker run 时,Docker 会在镜像层之上创建一个可写容器层。所有运行时产生的文件变更——日志写入、临时文件、应用数据——都发生在这个可写层。容器删除后,可写层也随之消失(除非你 commit 或挂载卷)。

用一张图来理解:

容器镜像分层与运行时结构 容器镜像分层与运行时结构 镜像(只读模板) Layer 5: 应用代码 Layer 4: 运行时依赖 Layer 3: 系统配置 Layer 2: 工具链 Layer 1: 基础操作系统 🔒 所有镜像层只读 docker run 容器(运行时实例) 可写容器层(临时) Layer 5: 应用代码 Layer 4: 运行时依赖 Layer 3: 系统配置 Layer 2: 工具链 Layer 1: 基础操作系统 ✏️ 容器层可读写 💡 关键理解: 镜像层被所有基于该镜像的容器共享;容器层独立,删除容器后数据丢失(除非使用卷挂载)。

⚠️ 避坑指南:我曾经在排查一个生产问题时发现,某团队在容器里写日志,日志文件越滚越大,最终撑满了磁盘。原因就是他们以为容器重启后日志会自动清理——但容器层是写时复制(Copy-on-Write),日志文件确实会随着容器删除而消失,可如果容器长期不重启,可写层会持续膨胀。后来我强制要求所有日志走 stdout/stderr,由容器编排平台统一收集。

1.4 镜像包含什么?—— 一个完整的应用环境

镜像之所以能「一次构建,到处运行」,是因为它打包了应用所需的一切。具体来说:

  • 基础操作系统文件:/bin、/usr、/lib、/etc 等目录结构,以及 glibc、ca-certificates 等基础库
  • 运行时环境:Python、Node.js、JVM、Go runtime 等
  • 应用代码与资源:编译后的二进制、脚本、静态文件、配置文件
  • 依赖库:pip 包、npm 模块、jar 包、系统级 .so 文件
  • 元数据:暴露端口、入口命令、环境变量、卷声明

说白了,镜像就是一个「快照」——把应用在某个时刻的完整运行环境冻结下来。你把它拉到任何一台装有容器引擎的机器上,都能复现出完全一致的环境。

实战建议:我个人习惯在构建镜像时,用 docker inspect 查看镜像的元数据。重点关注 RootFS.Layers 字段,它列出了所有层的 SHA256 摘要。如果你发现某个镜像的层数超过 30 层,就该考虑合并一些 RUN 命令了——层数太多会影响拉取和存储性能。

1.5 小结:理解镜像本质的三个层次

  1. 物理层:镜像是分层文件系统的集合,每层是一个 diff,通过 UnionFS 合并为统一视图
  2. 逻辑层:镜像是只读模板,容器是可写实例。镜像层共享,容器层独立
  3. 语义层:镜像打包了应用及其完整运行环境,实现了环境一致性和可移植性

这三个层次,是我在多年容器化实践中总结出来的理解框架。你如果能把这三点吃透,后面讲镜像加载、缓存、分发、安全扫描,都会顺很多。

嗯,这一章就到这里。记住一句话:镜像不是压缩包,是分层文件系统;容器不是虚拟机,是镜像的运行时实例。