Docker 镜像与层:0.1 秒完成构建的魔法原理

“啊? 刚刚那个构建就结束了?”

在体验过 Docker 的便利之后,我开始瞒着组长,一点一点把自己的开发环境迁到 Docker 上。我写了一份叫 Dockerfile 的设计图,然后敲下了 docker build 命令。

第一次构建花了不少时间。下载 Ubuntu、安装 Java、再把项目需要的库,Jar,拉下来,总共花了大约 5 分钟。“果然第一次会慢一些啊。”

可当我只是改了一个代码拼写错误,再次执行构建时,我简直不敢相信自己的眼睛。刚按下 Enter,终端里就跳出了 Successfully built。整个过程只用了 0.1 秒。

“不会是坏掉了吧? 刚才还要 5 分钟,怎么突然一下就结束了?”

我忐忑地把服务跑起来看了看,结果代码修改得一点没错。那 Docker 里面到底发生了什么? 这种离谱速度的秘密,就藏在一种叫“层(Layer)”的独特存储方式里。

Docker 不会从头重建,它只替换发生变化的积木块。

镜像(Image)与容器(Container)

在深入原理之前,先把两个最容易混淆的术语整理一下。

  • 镜像(Image):把可运行的“环境”牢牢冻结起来的设计图,也是原版印章。(只读)
  • 容器(Container):用印章“啪”地一下盖出来的实体,也就是真正在运行的程序。(可写)

我们在部署时发送到服务器上的,不是“容器”,而是“镜像”。服务器只需要接收这个镜像并运行它即可。

层(Layer):透明赛璐璐的魔法

Docker 镜像并不是一个单体的大文件。它更像是把很多张“透明赛璐璐”,也就是 Layer,一层层叠在一起。Dockerfile 里写下的每一行命令,都会分别变成一层。

比如这样。

# 第1層:安裝 Ubuntu(OS)
FROM ubuntu:20.04

# 第2層:安裝 Java
RUN apt-get install openjdk-17-jdk

# 第3層:複製我的程式碼
COPY my-app.jar /app/my-app.jar

# 第4層:執行
CMD ["java", "-jar", "/app/my-app.jar"]

如果把 Dockerfile 里的内容慢慢拆开来看,就会发现它的结构其实没有那么复杂。“装上 Ubuntu,FROM;安装 Java,RUN;把我的代码带进去,COPY;再运行,CMD。”

归根结底,这只是把我们以前在 VM 里或者服务器终端上手动敲的“服务器配置过程” 原封不动地写成了一份文档。有了这份文档,Docker 不需要别人一步步指挥,就会自己把安装流程完成。(这个概念之后也会和我们要讲的 “CI/CD 自动化” 的核心原理连在一起。)

但 Docker 为什么不把这个过程一口气打包成一个整体,而偏偏要按行拆开,一层一层地堆起来呢?

答案就在于 复用,也就是缓存(Caching)。如果我修改代码后再次构建,Docker 会很聪明地判断:“第一层,OS,和第二层,Java,跟刚才完全一样啊? 那就没必要重建,直接复用之前做好的缓存层就行。”

然后它只会从发生变化的第三层,也就是拷贝代码那一步开始重新构建。所以第二次构建才会在 0.1 秒内结束。

不变的部分先冻住并缓存,只重新烘焙变化的部分。

[Code Verification] 用眼睛亲自确认层缓存

别只停留在口头上,说它快不算数,我们直接用代码来确认。只要写一个简单的 Dockerfile,然后连续构建两次就行。

  1. 编写 Dockerfile:
FROM alpine:latest
RUN echo "1. 正在安裝基本工具..." && sleep 2 # 需要 2 秒的作業
COPY test.txt /app/test.txt
CMD ["cat", "/app/test.txt"]

  1. 第一次构建:
$ docker build -t my-test:v1 .
# 結果:
# [2/3] RUN echo "1. 正在安裝基本工具..." ... 2.1s (耗時 2 秒)

  1. 修改 test.txt 的内容后,进行第二次构建:
$ docker build -t my-test:v2 .
# 結果:
# [2/3] RUN echo "1. 正在安裝基本工具..." ... CACHED (0 秒!)

你看到日志里清清楚楚出现的 CACHED 这个词了吗? 这就是 Docker 跳过了原本需要 2 秒的那一步的证据。

实务建议:顺序就是生命

一旦理解了层缓存的原理,你就会知道 Dockerfile 应该怎么写。核心原则很简单:“不常变化的内容放在下面,也就是前面;经常变化的内容放在上面,也就是后面。”

坏例子:

# 1. 先複製原始碼(經常變動!)
COPY . . 
# 2. 安裝函式庫(幾乎不變)
RUN npm install

源代码一天会变动几十次。如果第 1 步发生变化,Docker 连后面的第 2 步,安装库,缓存也会一并作废,然后重新执行。你只是改了一行代码,却还得重新等一次 npm install。

好例子:

# 1. 先只複製函式庫清單(package.json)
COPY package.json .
# 2. 安裝函式庫(即使原始碼變動,這一步仍會被快取!)
RUN npm install
# 3. 複製原始碼
COPY . .

哪怕只是换一下顺序,构建速度也能提升 10 倍。这就是 Docker 新手和真正高手之间的差别。

实务建议 2:标签(Tag)就是生命线

在上面的代码里,我写了 docker build -t my-test:v1 .。这里冒号 : 后面的 v1,就是标签,Tag。

很多初学者嫌麻烦,不给镜像打标签。这样一来,Docker 就会自动给它加上 latest 这个标签。

  • my-test:latest:意思是“最新版本”。
  • 问题:如果每次都只用 latest 覆盖,那么服务器出错时,“哎? 帮我退回昨天还好好的那个版本!”就成了不可能的事。因为它早就被覆盖掉,已经不存在了。
  • 解决办法:一定要像 v1、v2 或 日期-时间 这样打上唯一的标签再构建。这样一旦出了事故,你才能扔掉 v2、换回 v1,1 秒内完成回滚。

这个打标签的小习惯,以后会帮你守住很多下班时间。

结语:把 0.1 秒的魔法握在手里

Docker 镜像并不是单纯的一堆文件,而是为了把环境以最高效率交付出去而层层堆叠起来的 技术结晶。只要理解层缓存,并把 Dockerfile 的顺序安排好,我们就能构建出轻量、快速、甚至能在 0.1 秒内完成构建的部署环境。

现在,把我电脑里的环境完美冻结成一个 Image 并发送到服务器上的准备,已经全部完成了。

但真到了要在服务器上启动这个镜像的时候,新的问题又出现了。一个 Web 服务并不是只靠一个服务器容器就能运行起来的。它还需要数据库、Redis,以及前端服务器。

这么多容器,难道要靠一条条命令一个个启动和关闭吗? 它们之间又该怎么通信? 当我们不再只讨论单个“镜像”,而是一个完整“应用”时,该怎么组织它? 下一次,我们就来看看 Docker 世界观进一步扩展后的概念:容器、服务,以及栈。

發佈留言