镜像与分层
镜像 vs 容器、Union FS、OCI。
镜像与分层
这一课你将能做到
- 亲手用
pull、images、history、inspect 四个命令把一个镜像「拆开看」,说出每一层是什么
- 用实验证明「镜像只读、容器可写」:改容器内文件、删除容器、重建后对比数据去向
- 说清 tag 与 digest 的区别,明白
mysql:8.0、8.0.40、latest 三者的真实关系
为什么要学
不理解分层,后面的课程全是黑盒:为什么 Dockerfile 指令顺序影响构建速度?为什么两个 Spring Boot 镜像只占一份 JRE 的磁盘?为什么 docker push 只推了 3MB?答案全在「分层」两个字上。
对 Java 后端更实际的意义:你的应用镜像 = 基础镜像(OS + JRE)+ 依赖层 + 你的代码层。理解结构,才能在第 6 课做构建优化、在第 7 课正确管理版本、在第 11 课评估安全风险(哪层引入了漏洞)。
先建立图像
镜像是「只读层的堆叠」,容器在此基础上加一个可写层:
┌─────────────────────────┐
│ 容器可写层(Writable) │ ← docker exec 写的东西在这
├─────────────────────────┤
│ app.jar 层 (只读) │ ← 你 COPY 的代码
镜像 = ├─────────────────────────┤
一叠只读层 │ 依赖/配置层 (只读) │
├─────────────────────────┤
│ JRE 层 (只读) │
├─────────────────────────┤
│ Debian/Alpine 基础层(只读)│
└─────────────────────────┘
三个关键机制:
- 层可复用:你的两个 Spring Boot 镜像都用
eclipse-temurin:21-jre,那 OS 层和 JRE 层磁盘上只存一份。
- 写时复制(CoW):容器要改只读层里的某个文件,先把文件复制到可写层再改,原层永远不动。
- 删除是「白名单遮蔽」:容器里删一个来自只读层的文件,只是在可写层标记删除,镜像里文件仍然存在。
跟着做
步骤 1:拉镜像,观察分层下载
docker pull mysql:8.0
输出类似:
db6fc19d3c9b: Pull complete
a39b63fa0f31: Pull complete
...
Digest: sha256:abc123...
Status: Downloaded newer image for mysql:8.0
每一行就是一个层。如果之前拉过共享基础镜像,会看到 Already exists——这就是层复用的直接证据。
步骤 2:查看本地镜像列表
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
mysql 8.0 f5da8161f17e 3 weeks ago 573MB
redis 7 1a35c5b13c8e 4 weeks ago 117MB
列含义:REPOSITORY+TAG 是「名字」,IMAGE ID 才是「本体」,SIZE 是解压后的逻辑大小(多层共享,实际磁盘占用小于各行相加)。
步骤 3:用 history 看每一层怎么来的
docker history mysql:8.0
每行对应镜像构建时的一个 Dockerfile 指令(RUN/COPY/ENV...),从下往上读就是构建顺序。以后你自己的镜像构建出问题, 是还原「我当时干了什么」的第一工具。
history
步骤 4:用 inspect 查镜像元数据
重点字段:.Architecture(amd64 还是 arm64,跨机器迁移必看)、.Config.Env(默认环境变量)、.Config.ExposedPorts(声明了哪些端口)、.RootFS.Layers(层的 sha256 列表)。
步骤 5:实验——证明镜像只读、容器可写
# 起一个 redis 并进容器写个文件
docker run -d --name img-test redis:7
docker exec img-test sh -c "echo important > /data/proof.txt"
docker exec img-test cat /data/proof.txt # 输出 important
# 停止、删除、用同一镜像重建
docker rm -f img-test
docker run -d --name img-test2 redis:7
docker exec img-test2 cat /data/proof.txt # 报错:No such file
结论:容器可写层随 rm 销毁,镜像毫发无损。数据库数据放可写层 = 定时炸弹(第 8 课解决)。
步骤 6:tag 与 digest
docker tag redis:7 my-redis:backup # 同一镜像多一个名字,IMAGE ID 不变
docker images | findstr redis # 两个名字指向同一个 ID
docker rmi my-redis:backup # 删的只是别名
docker images --digests # 查看 digest(内容哈希)
tag 是可变的别名(8.0 会被官方更新覆盖),digest(sha256:...)由镜像内容算出,不可变。生产环境要锁死版本,就用 digest 拉取。
步骤 7:清理无用镜像
docker rmi mysql:8.0 # 删指定镜像(有容器在用会拒绝,先删容器)
docker image prune # 清理 <none> 的悬空镜像(dangling)
docker image prune -a # 清理所有没被容器使用的镜像(慎用)
讲清楚要点
- 镜像 = 配置 + 只读层堆叠:不只是文件系统,还包含启动命令(Cmd/Entrypoint)、环境变量、暴露端口等元数据。所以「同一镜像」保证的不只是文件一致,还有默认行为一致。
- 层的复用单位是「内容哈希」:层内容不变,哈希不变,可被任意镜像共享。这是 push/pull 增量传输、构建缓存的基础,后面两课都建立在这上面。
- tag 是别名,digest 是身份证:
latest 只是没有显式指定 tag 时的默认名,不代表最新版本,更不代表稳定。生产镜像引用要么精确到补丁号(mysql:8.0.40),要么直接用 digest。
- 镜像大小 ≠ 磁盘占用:
docker images 的 SIZE 是各层解压后的独立大小;由于层共享,docker system df 才是真实磁盘账单。
- 架构必须匹配:Apple Silicon(arm64)默认拉 arm64 镜像,部署到 amd64 服务器会报
exec format error。跨架构要么在构建时指定 --platform linux/amd64,要么用 buildx 多架构构建(第 6 课)。
你可能会懵的地方
-
为什么 pull 一个 MySQL 要下载那么多层?
MySQL 镜像 = Debian 基础层 + 依赖库层 + MySQL 本体层……层层堆叠。而且这些层和别的官方镜像共享,拉过一次,其他镜像的对应层秒下。
-
两个不同 tag 显示同一个 IMAGE ID,是 bug 吗?
不是。docker tag 只是加了个别名,底层数据完全共享。删除其中一个名字,另一个照常可用。
-
容器里删除了一个文件,镜像会变大吗?
不会。删除只是可写层里的「标记」,镜像内容不变。但要明白:想真正去掉镜像里的文件,只能重新构建(且要在它所在层之后删才有意义——同层内删文件不减体积,这是第 6 课优化点)。
-
8.0-alpine 和 8.0 有什么区别?该用哪个?
alpine 版把基础系统换成 5MB 的 Alpine Linux,体积小很多;但它是 musl libc,个别依赖 glibc 的库会踩坑。MySQL/Redis 官方 alpine 版很成熟可放心用;Java 应用选 alpine 镜像需测试字体、时区等细节,稳妥选择是 slim/JRE 变体。
-
docker images 里一堆 <none>:<none> 是什么?
悬空镜像(dangling):重新构建同名 tag 后,旧镜像失去了名字但数据还在。docker image prune 一键清理。
错法对照
| 错法 | 问题 | 正解 |
|---|
生产环境引用 latest | 每次拉取版本可能不同,出问题无法复现 | 固定到具体版本号或 digest |
| 以为容器内改文件能「修正」镜像 | 改动随容器删除而消失 | 修改 Dockerfile 重新构建 |
| 数据库数据存容器可写层 | rm 容器即丢数据 | 挂数据卷(第 8 课) |
| 在 arm64 Mac 上构建后直接推给 amd64 服务器 | 运行报 exec format error | 构建时 --platform linux/amd64 |
用 docker rmi 删镜像报「image is being used」 | 有容器还挂着它 | 先 docker rm 相关容器再删镜像 |
| 磁盘爆了才想起来清理 | dangling 镜像悄悄堆积 | 定期 docker image prune |
卡点急救
- pull 报
no matching manifest for linux/arm64:镜像不支持你的 CPU 架构。加 --platform linux/amd64 强制拉 amd64(会走模拟,性能略降)。
docker rmi 提示多个 tag 混乱删不干净:按 IMAGE ID 删:docker rmi <image-id>,一次清掉所有别名。
- 怀疑本地镜像被污染/想重置:
docker rmi 镜像名 后重新 docker pull;极端情况用 docker system prune -a(会删掉全部未使用镜像,先确认)。
- 两个镜像看起来一样但 ID 不同:构建时间或指令有差异,用
docker history 对比每层来源,定位差异层。
- 磁盘占用远超镜像列表总和:容器可写层、构建缓存、卷都在占空间。
docker system df 分类查看,docker system prune 统一清理。
面试常问
-
镜像分层的好处是什么?
①存储复用:多镜像共享基础层,省磁盘;②传输高效:pull/push 只传本地缺失的层;③构建缓存:指令未变的层直接复用,加速 CI;④升级最小化:只改代码层时,其余层原封不动。
-
解释写时复制(Copy-on-Write)?
容器启动时不需要复制整个镜像,只在修改某文件时才把它从只读层复制到自己的可写层再修改。这让「同一镜像秒级起 N 个容器」成为可能。
-
tag 和 digest 的区别?生产环境怎么引用镜像?
tag 是可变别名(可被重新指向),digest 是内容寻址的不可变标识(sha256)。生产环境推荐 镜像:具体版本 并在发布单里记录 digest;要绝对不可变就用 digest 直接拉取。
-
容器删除后,写入的数据去哪了?
可写层随容器一起销毁,数据彻底消失。所以持久化数据必须放 volume——这是有状态服务容器化的前提。
自测三问
- 画图说明镜像的层结构与容器的可写层关系,并解释 CoW 过程。
mysql:8.0 和 mysql:8.0.40 可能是同一镜像吗?latest 呢?如何验证?
- 为什么说「两个 Spring Boot 镜像各 300MB,但磁盘可能只增加 350MB」?
小结一句话
镜像是「配置 + 一叠内容寻址的只读层」,容器只是在其上多了一个可写层——分层带来复用与缓存,可写层的短命决定了持久化必须依赖数据卷。
延伸