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

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

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

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

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

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

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

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

镜像(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,然后连续构建两次就行。

    FROM alpine:latest
    RUN echo "1. 正在安裝基本工具..." && sleep 2 # 需要 2 秒的作業
    COPY test.txt /app/test.txt
    CMD ["cat", "/app/test.txt"]
    
    
      $ docker build -t my-test:v1 .
      # 結果:
      # [2/3] RUN echo "1. 正在安裝基本工具..." ... 2.1s (耗時 2 秒)
      
      
        $ 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 这个标签。

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

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

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

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

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

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

        發佈留言