4、Docker镜像深入:镜像分层原理、UnionFS、写时复制、镜像构建缓存、查看镜像历史
好,咱们今天来聊聊Docker镜像。说实话,镜像这东西,你要是只停留在「docker pull」和「docker run」的层面,那跟没学没啥区别。真正让你在生产环境里翻车的,往往就是对镜像底层机制的一知半解。我当年刚接手微服务架构时,就被镜像层数过多导致的构建缓慢坑过一次,那叫一个酸爽。
咱们今天就把镜像的底裤扒干净。从UnionFS到写时复制,从缓存机制到历史查看,一个一个来。
4.1 镜像分层:为什么Docker镜像不是一整块?
你想想看,如果每个镜像都是一整块巨大的文件,那每次更新代码都得重新下载整个操作系统,这谁受得了?Docker的精明之处就在于——它把镜像拆成了一层一层的。
每一层,本质上就是一个文件系统的快照。比如你的基础镜像Ubuntu是一层,上面装了个Python又是一层,再上面放你的代码又是一层。每一层都是只读的,只有最上面的容器层才是可写的。
我个人的习惯是,把镜像想象成一个千层蛋糕。每一层都独立存在,但叠在一起就成了一个完整的系统。这样做的好处太明显了:
- 复用性:多个镜像可以共享底层,比如你的Java应用和Python应用都可以共用同一个Ubuntu基础层
- 节省空间:同样的层只存一份,磁盘占用大幅降低
- 加速传输:更新时只传输变化的层,而不是整个镜像
我在项目中遇到过,一个团队把基础镜像从Ubuntu换成了Alpine,结果构建时间从5分钟降到了40秒。为什么?因为Alpine只有5MB,而Ubuntu有200MB。层数少了,传输和加载自然就快了。
核心要点:镜像层是只读的,容器层是可写的。所有对文件的修改,都发生在最顶层的容器层。
4.2 UnionFS:把多个文件系统合并成一个
UnionFS,全称是Union File System,翻译过来就是「联合文件系统」。说白了,它的作用就是把多个目录「联合」挂载到一起,对外呈现为一个统一的文件系统。
Docker早期用的是AUFS,后来换成了OverlayFS(现在主流是Overlay2)。但不管底层是啥,原理都一样:
- 把镜像的每一层都挂载到某个目录下
- 通过UnionFS把这些目录「叠」在一起
- 用户看到的是一个完整的文件系统,根本感觉不到分层
举个例子,假设你有三层:
底层(layer1):/bin, /lib, /usr
中间层(layer2):/usr/local/python
顶层(layer3):/app/mycode
UnionFS会把它们合并成一个:
/bin → 来自layer1
/lib → 来自layer1
/usr/local/python → 来自layer2
/app/mycode → 来自layer3
嗯,这里要注意:如果不同层有同名文件,上层会覆盖下层。这就是为什么你在自己的Dockerfile里改了某个配置文件,它不会影响到基础镜像里的原始文件。
小技巧:你可以用 docker inspect <镜像名> 查看镜像的分层信息,里面会列出每一层的ID和大小。
4.3 写时复制(Copy-on-Write):修改文件时发生了什么?
写时复制,英文叫Copy-on-Write,简称CoW。这个名字起得挺直白——「在写入的时候才复制」。
你可能会问:既然镜像层是只读的,那我在容器里修改一个文件,到底是怎么做到的?
答案是:Docker不会直接修改镜像层里的文件。它会先把那个文件从镜像层「复制」到容器层,然后在容器层里修改。这就是写时复制的核心思想。
我举个例子你就明白了:
- 你有一个Ubuntu镜像,里面有个
/etc/nginx/nginx.conf - 你在容器里改了它
- Docker发现这个文件在镜像层(只读),不能直接改
- 于是它把文件复制到容器层(可写层)
- 然后在容器层里修改
- 从此以后,你看到的就是容器层里的新文件,镜像层里的旧文件被「隐藏」了
我曾经踩过一个坑:有个同事在容器里写日志,日志文件越来越大,结果容器层撑爆了磁盘。为什么?因为每次写日志都是在容器层里写,而容器层是有限制的。后来我们改成了挂载外部卷,才解决了这个问题。
避坑指南:写时复制虽然高效,但频繁修改大文件会导致性能下降。因为每次修改都要先复制整个文件到容器层。我曾经见过一个案例,有人在容器里频繁修改一个几百MB的数据库文件,结果容器启动越来越慢。解决方案是把这类文件放到挂载卷里。
4.4 镜像构建缓存:为什么第二次构建这么快?
你有没有发现,Dockerfile构建镜像时,第二次往往比第一次快得多?这就是缓存的功劳。
Docker在构建时,会为每一层生成一个缓存键。这个缓存键基于:
- 当前层的指令(比如
RUN apt-get update) - 上一层的内容(通过哈希计算)
- 构建上下文中的相关文件
如果缓存键匹配,Docker就直接复用之前的层,跳过执行。这就是为什么你改了一行代码,只有那一层和它后面的层需要重新构建。
我个人的建议是,在写Dockerfile时,把「不常变」的指令放在前面,「经常变」的指令放在后面。比如:
# 好的写法:不常变的放前面
FROM ubuntu:20.04
RUN apt-get update && apt-get install -y python3 nginx
COPY requirements.txt /app/
RUN pip install -r /app/requirements.txt
COPY . /app/ # 经常变的放最后
这样你改代码时,只需要重新构建最后两层,前面的都能命中缓存。如果你把 COPY . /app/ 放在前面,那每次改代码都会导致所有层重新构建,那缓存就白搭了。
核心要点:缓存命中的前提是「指令完全一致」且「上一层内容完全一致」。如果你改了 apt-get install 里的包名,那这一层和它后面的所有层都会失效。
4.5 查看镜像历史:每一层都干了什么?
有时候你拿到一个别人构建的镜像,想知道里面到底装了啥,怎么办?用 docker history 命令。
这个命令会列出镜像的每一层,以及每一层对应的指令和大小。比如:
$ docker history nginx:latest
IMAGE CREATED CREATED BY SIZE
abc123def456 2 weeks ago /bin/sh -c #(nop) CMD ["nginx" "-g" "daemon… 0B
def456ghi789 2 weeks ago /bin/sh -c #(nop) STOPSIGNAL SIGQUIT 0B
ghi789jkl012 2 weeks ago /bin/sh -c #(nop) EXPOSE 80 0B
jkl012mno345 2 weeks ago /bin/sh -c #(nop) ENTRYPOINT ["/docker-entr… 0B
mno345pqr678 2 weeks ago /bin/sh -c #(nop) COPY file:09a214a3e07c919a… 1.2kB
...
你看,每一层都清清楚楚。哪一层加了什么文件,哪一层执行了什么命令,一目了然。
我在排查镜像体积问题时,经常用这个命令。有一次发现一个镜像居然有1.2GB,用 docker history 一看,发现中间有一层执行了 apt-get install 后没有清理缓存,白白多了300MB。后来我在Dockerfile里加了 && rm -rf /var/lib/apt/lists/*,镜像体积直接降了一半。
小技巧:docker history 加上 --no-trunc 参数可以显示完整的指令内容,不会被截断。另外,docker image inspect 也能看到分层信息,但不如 history 直观。
4.6 实战建议:如何写出高效的Dockerfile?
说了这么多理论,最后给几个实战建议。这些都是我在生产环境里摸爬滚打总结出来的:
- 合并RUN指令:多个
RUN会生成多个层,尽量用&&合并成一个。比如RUN apt-get update && apt-get install -y curl比分开写要好。 - 清理临时文件:安装包后记得清理缓存,比如
apt-get clean或rm -rf /var/cache/apk/*。 - 利用多阶段构建:构建环境和运行环境分开,最终镜像只保留运行所需的最小文件。
- 注意层数限制:虽然Docker没有硬性限制层数,但层数太多会影响性能。我一般控制在20层以内。
- 使用.dockerignore:排除不需要的文件,避免构建上下文过大导致缓存失效。
嗯,今天就先聊到这儿。镜像分层这个知识点,说白了就是理解「层」的概念。你只要记住:镜像层只读、容器层可写、UnionFS负责合并、CoW负责按需复制。掌握了这些,你就能写出更高效、更小巧的镜像了。
下一章咱们聊聊容器网络,那又是一个容易踩坑的地方。