想象你在一家外卖中央厨房工作。顾客只点了一份三明治,但传统做法是:把整个备餐间——灶台、菜刀、一袋袋食材、用过的油、甚至垃圾桶——连同一只保温盒一起塞进外卖箱。骑车的人越走越沉,顾客还嫌盒子难闻、不卫生。Docker 里的单阶段构建就是这个味道:一个 Go 程序明明只有 8MB,镜像却常常超过 500MB,里面躺着编译器、依赖缓存、临时文件和包管理日志。 多阶段构建则像一个纪律严明的后厨:备餐间可以随便大,但出餐窗只递出一只整齐的保温盒,里面放做好的菜。于是镜像又小又干净,拉取快、启动快、攻击面小。这篇文章会带你把多阶段构建的原理、builder 模式、基础镜像选择、.dockerignore、缓存优化、安全扫描和常见踩坑一次讲清。
掌握多阶段构建,能显著降低镜像体积与交付风险,是部署 AI 服务和微服务时绕不开的工程基本功,也是 CI/CD 工程师观察力的重要分水岭。
可视化演示
展示Docker多阶段构建流程、镜像体积对比及构建缓存优化策略,直观理解如何构建又小又干净的生产镜像。
本节配有交互动效(进入视口后自动加载,约 3KB)。
多阶段构建流程 Builder阶段编译源码产出二进制(585M) 仅复制二进制到Runtime阶段(27M) Final = Runtime(base + bin) 多阶段构建只保留运行所需文件丢弃编译依赖
镜像膨胀根源
回到中央厨房。一份三明治只需要面包、生菜、鸡胸肉,但你打开外卖箱,发现里面还塞着整套刀具、平底锅、半袋土豆、三桶酱料和昨天的厨余垃圾。这正是传统单阶段 Dockerfile 的日常:为了让一个很小的服务跑起来,以 ubuntu 为基础镜像,apt-get install 装编译器,npm install 拉全部依赖,最后连源码和缓存一起塞进镜像。
为什么这些杂物甩不掉?因为 Docker 镜像不是一块硬盘快照,而是一层一层叠加的只读层。每一条 RUN、COPY、ADD 都会生成一个新层,文件被添加到新层里。你在后面的层里执行 rm -rf,旧层中的文件不会真正消失,只是被新层标记为“删除”;只要有人提取旧层,文件立刻能回来。所以“装完再删”这条路被 Docker 层模型堵死了。
所以镜像膨胀的本质,是把备餐间的一切都装进了保温盒。要想外卖箱又轻又干净,不是往盒子里补一层“垃圾盖”,而是干脆不把厨房带出去——这正是下一节多阶段构建要解决的事。
- 基础镜像
base_image镜像的起点,例如 ubuntu 自带约 70MB 的系统和常用库。 - 求和符号
\sum把每一条会生成层的指令都累加进来。 - 单层增量
\Delta layer_i每条 COPY/RUN 增加的体积,删除动作也只会产生正增量。
多阶段构建原理
中央厨房做事讲分工:备餐间负责切、煮、炖,出餐台只管把成品装进保温盒。多阶段构建用多个 FROM 模拟这个分工。第一个 FROM 是备餐间,第二个 FROM 是出餐台,两者之间只隔一个小窗口。
窗口的学名是 COPY --from=阶段名。只要在某个阶段用 AS builder 命名,后续阶段就能从那个阶段里精确拷贝文件。最终镜像由最后一个阶段决定,前面阶段即使装了几百兆工具,也不会被“带出厨房”。
上面的例子只有两个阶段:builder 阶段用 Go 工具链编译出 server;最终阶段从 scratch 空镜像开始,只做一次 COPY,把 server 放进来。整个保温盒里只有一个可执行文件。
可以看到,多阶段构建不是“删掉不需要的层”,而是“从未把不需要的层装进最终镜像”。备餐间还在仓库里,但外卖箱里只有三明治。
- 最终阶段起点
scratchFROM scratch让最终镜像从零开始,不带任何工具。 - 产物集合
a_j需要从各构建阶段拷贝的文件集合。 - 拷贝操作
COPY(a_j)COPY --from把产物放进最终镜像,等价于只装便当。
FROM golang:1.21 AS builderWORKDIR /appCOPY . .RUN go build -o server . FROM scratchCOPY --from=builder /app/server /serverENTRYPOINT ["/server"]
builder模式
说回中央厨房,你会专门给招牌菜配一间“备餐间”:里面有商用烤箱、搅拌机、喷枪,工人可以放飞自我。这就是 builder 模式的名字来源——第一个阶段被命名为 builder,专门负责构建产物。
builder 模式的思想是:构建阶段想用多大镜像都行,要能编译、能跑测试、能生成二进制;运行阶段则回到最简单的基础镜像,只保留可执行文件和必要的运行库。对于 Go、Rust 这类静态编译语言,最终阶段可以完全没有 shell、包管理器、编译器。
如果程序是 C 写的,依赖了动态库,别怕:在构建阶段用 ldd 列出 .so 文件,再在 COPY --from 中把这些库一并放到 lib 目录。这一步相当于把酱料、配菜单独装进便当盒的小格子,而不是连整个酱料桶搬上车。
builder 模式是使用最广的多阶段构建套路:备餐间再乱,出餐盒永远干净。
- 极小基底
minimal_base运行阶段的基础镜像,不含包管理器与编译器。 - 产物集合
artifacts用 ldd、项目打包配置等列出的必要文件集合。 - 逐个拷贝只把产物和必要的动态库放入最终镜像。
基础镜像优化
给外卖选盒子:有人用厚重的不锈钢饭盒,有人用轻便纸盒,还有人用一片荷叶把饭团一包。Docker 里也有这样的选择——基础镜像就是你的“盒子”。
scratch 是真正的空镜像,里面连 /bin/sh 都没有,体积为 0,适合静态二进制;alpine 基于 musl libc,完整基础镜像约 5MB,适合大多数可静态链接或 musl 兼容的程序;distroless 是 Google 提供的一组镜像,包含 Java、Python 等运行时,但没有 shell 和包管理器,安全和体积兼顾。选盒原则:够用就好,不要贪大。
选对盒子,重量从“一箱厨具”降到“一片荷叶”。多阶段构建的最后一步,常常就是在这些轻量基础镜像上完成的。
- 近似符号
\approx暂不考虑文件系统元数据和压缩率差异。 - 基础镜像体积
base_size选得越小,留给杂物的空间越少。 - 趋近于零
\to 0scratch 的 base_size 为 0,成品几乎就是全部。
上下文瘦身
备餐间要开工,先把今天用不上的食材、旧菜单、废纸箱挡在后门外。.dockerignore 就是这个后门门卫。
docker build 启动时,Docker 客户端会把当前目录整个打成 context 发给 daemon。如果目录里有 .git、node_modules、dist、__pycache__、.env,它们都会坐进这趟“进货卡车”。不仅上传慢,一旦 Dockerfile 里写了 COPY . .,这些文件还会被原样装进备餐间,甚至进入最终镜像。
在项目根目录放一个 .dockerignore,用 .gitignore 的语法排除目录和文件。注意:它排除的是构建上下文,不是自动减基础镜像;如果显式 COPY 一个被忽略的文件,构建会直接报错——这反而是保护机制。
门卫挡掉的不是菜,而是所有不该进后厨的东西。少传一个 .git,可能比后续所有瘦身技巧更立竿见影。
- 全部文件
\mathcal{F}构建上下文根目录下所有文件集合。 - 忽略文件
\mathcal{I}.dockerignore规则匹配到的文件集合。 - 差集
\setminus实际发送给 Docker daemon 的文件。
构建缓存优化
老汤店不会每天从洗锅开始熬高汤:只要汤底配方不变,昨天的老汤直接接着用。Docker 的层缓存,就像那锅老汤。
Docker 执行某条指令前,会检查指令文本和涉及的上下文文件是否与上次构建一致;如果一致,就直接复用缓存层,不再重新执行。为了喝到老汤,要把不常变的步骤放前面:先 COPY go.mod go.sum,再 RUN go mod download,最后 COPY . .。源码一改,前面的依赖层仍然命中。
如果反过来把 COPY . . 放最前面,任何源码变化都会让后面所有 RUN 全部重跑,缓存几乎全部失效。较新版本的 Docker 还支持 RUN --mount=type=cache,可以把 apt、pip 的下载目录挂到缓存卷,反复构建更快。
高汤前置、炉火不熄,便当出餐自然快。这也是为什么依赖与代码的顺序,常常比镜像体积数字更能影响工程师的幸福感。
- 命中标记
h_i第 i 层是否命中缓存,命中为 1,未命中为 0。 - 执行耗时
t_i第 i 层重新执行命令需要的时间。 - 只算未命中层
1 - h_i让命中层贡献为 0,未命中层贡献完整耗时。
镜像安全扫描
外卖盒封口前,质检员要过一道安检:食材有没有临期,包装有没有破损,盒子里有没有混入钢针。镜像安全扫描就是 Docker 世界的质检员。
扫描工具 Trivy、Grype 会把镜像每一层里的软件包、二进制文件与 CVE 漏洞库比对,给出严重等级。镜像越小、包越少,扫描面越小;这正是多阶段构建顺带带来的安全红利——没有编译器,就没有编译器漏洞;没有 shell,攻击者就没法轻易交互。
在 CI 中可以让扫描成为门禁:trivy image --exit-code 1 --severity HIGH --ignore-unfixed your-image,一旦发现高危漏洞就让流水线失败。还要切记:不要把数据库密码、API Key 写进 ENV 或 RUN 命令,镜像历史会留下痕迹。
安检通过,便当才会被递出窗外;扫不出高危,不只是“更安全”,也是让团队睡得着觉的硬指标。
- 漏洞集合
vulns(image)从镜像层中识别出的所有已知漏洞。 - 严重度
score(v)每个漏洞的 CVSS 分数或严重等级。 - 取最大值
\max只要有一个高危漏洞,整体风险就是高危。
实践与踩坑
会做便当不代表每次都能完美出餐。现实中,多阶段构建也有几个反复出现的翻车现场,就像把外卖小票和家里钥匙一起塞进了保温盒。
先讲应该怎么做:把最不容易变的 COPY 和 RUN 放前面;单条 RUN 里顺便清理 apt/pip 缓存;需要动态库时就 ldd 后精确拷贝 .so;所有阶段都用 AS 起清晰的名字,避免搞混。
再讲常踩的坑:一是以为 rm -rf /var/cache 能减镜像,其实旧层还在;二是最终阶段仍用 ubuntu 这种大镜像,前功尽弃;三是把密钥写进 ENV,任何人都能拉取镜像历史看到;四是阶段名拼错或漏掉 --from,构建直接失败。多阶段构建不是银弹,它是帮你把“必要文件、工具、临时文件、密钥”四者分开的手术刀。
一次成功的出餐,是把对的菜装进对的盒子,再通过对的窗口递出去。愿你下一次 docker build,也能收获一只轻巧、干净、安心的保温盒。
- 必要文件
necessary_files程序运行期真正需要的二进制、配置和运行库。 - 三类垃圾构建工具、临时文件、密钥合在一起。
- 做减法
\setminus从必要文件中剔除垃圾,才是最终镜像。
AI 应用场景
模型服务镜像从 6GB 砍到 800MB
阶段:模型部署与上线
解决的问题:AI 模型服务通常是 Python + CUDA + 一堆 pip 包,单阶段构建会把编译缓存、wheel 包、源码和训练脚本一并打进镜像,动辄几个 GB。多阶段构建先在 builder 阶段安装依赖并编译扩展,最终阶段装 runtime 与模型文件,只保留服务依赖。
具体案例:某算法团队用多阶段构建部署 PyTorch 推理服务,把镜像从 6.2GB 减到 800MB,内网机器拉取时间从 5 分钟降到 40 秒,安全扫描的高危漏洞也少了一半。
CI 流水线构建与扫描门禁
阶段:持续集成/持续交付
解决的问题:项目组每个人各自构建镜像,标准不统一,镜像忽大忽小,漏洞没人管。用 Dockerfile lint + 多阶段构建 + Trivy 扫描作为 CI 门禁,保证每次提交产出的镜像都是最优且可追溯的。
具体案例:GitHub Actions 中 docker buildx build --push,随后 trivy image --exit-code 1 --severity HIGH,如果发现高危漏洞就阻止合并请求。持续两个月后镜像平均体积下降 70%。
边缘设备上的轻量模型网关
阶段:边缘推理
解决的问题:树莓派/工控机存储只有十几个 GB,镜像如果很大,更新一次就吃满磁盘。多阶段构建把 Go 写的模型网关编译成静态二进制,放进 scratch 镜像,整个镜像只有 10MB 左右。
具体案例:一个边缘项目用 FROM golang:1.21 AS builder 编译网关,再用 FROM scratch 拷贝 gateway 和 .so 模型文件,设备通过 OTA 更新镜像,带宽成本降低了 95%。
Python 演示
#!/usr/bin/env python3# -*- coding: utf-8 -*-"""Simulate image size for single-stage vs multi-stage Dockerfile.""" class Layer: def __init__(self, label, delta_mb): self.label = label self.delta_mb = delta_mb def single_stage(base_mb, layers): total = base_mb for layer in layers: total += layer.delta_mb return total def multi_stage(runtime_base_mb, artifact_mb, build_layers): temp_size = runtime_base_mb + sum(l.delta_mb for l in build_layers) # temp_size is not part of the final image return artifact_mb BUILD_LAYERS = [ Layer("COPY source", 20), Layer("apt-get install gcc", 420), Layer("go mod download", 310), Layer("go build", 16),]RUNTIME_BASE_MB = 0 # scratchARTIFACT_MB = 9 def main(): single = single_stage(72, BUILD_LAYERS) multi = multi_stage(RUNTIME_BASE_MB, ARTIFACT_MB, BUILD_LAYERS) print("single-stage image: {} MB".format(single)) print("multi-stage image: {} MB".format(multi)) saved = (1 - multi / single) * 100 print("size saved: {:.1f}%".format(saved)) if __name__ == "__main__": main()
class Layer把 Dockerfile 中每一条会生成镜像层的指令建模为一个对象。def single_stage单阶段构建把所有层体积依次累加,对应“把厨房也装进外卖箱”。def multi_stage多阶段构建只返回运行基础镜像与产物体积,构建阶段层被丢弃。BUILD_LAYERS用几条典型指令表示编译器、依赖下载和编译产物。RUNTIME_BASE_MB = 00 表示scratch空基础镜像。saved = (1 - multi / single) * 100计算多阶段相对单阶段节省的体积百分比。if __name__ == "__main__"保证脚本可以直接运行,也方便被其他模块导入。
常见误区
误区“我用多阶段构建之后,镜像变小了,那第一阶段的几百兆层是不是也自动被删了?本地磁盘怎么还会被占满?”
正解多阶段构建只决定“最终镜像由哪些层组成”,不会自动删除已经产生的构建缓存。第一阶段层会留在本机 Docker 的缓存目录中,直到你执行 docker builder prune 或 docker system prune。就像备餐间虽不出菜,但厨房还在那里占着地方。
为何常见:人们把“最终镜像”和“构建环境”混为一谈,以为镜像变小等于所有现场都被清理了。
误区“安装完依赖后,用 `rm -rf /var/lib/apt/lists/*` 删除缓存,镜像应该会变小吧?”
正解Docker 的镜像是分层叠加,删除操作只是新写入一层“已删除”的标记,旧层中的文件仍然存在于镜像中。真正的瘦身靠两层:一是不装不需要的东西,二是用多阶段构建不把构建层拷贝进最终镜像。前者像做了也不加分,后者像根本没把东西放进外卖箱。
为何常见:因为普通文件和 Docker 层的删除语义不同,很多人照搬 Linux 习惯。
误区“scratch 是零体积镜像,我把 Python、Java 或 C 程序复制进去就能跑。”
正解scratch 没有 libc、没有 Python/JVM 运行库。Go/Rust 静态编译的程序可以跑;C 程序需要把动态库也拷进去;Python/Java 需要相应的运行时,通常更适合 distroless 或自建 runtime 基础镜像。就像荷叶包饭适合干爽的饭团,不适合汤汁四溢的红烧肉,除非额外加个托盘。
为何常见:被“scratch 零体积”宣传吸引,忽略了程序运行环境依赖。
误区“项目里加了 .dockerignore,密钥和 .env 就绝对进不了镜像。”
正解.dockerignore 只会让这些文件不进入构建上下文;如果 Dockerfile 里显式执行 COPY . .,它也会一起被过滤,但如果你写 COPY secrets.txt . 或把文件放在未忽略的目录且没被规则覆盖,敏感文件照样被复制。需要在 Dockerfile 里检查每一条 COPY/ADD,而不是只靠 .dockerignore 兜底。
为何常见:以为忽略规则等同于一把万能锁,其实它是门卫,不是保险柜。
误区“多阶段构建好,那我在 Dockerfile 里写十几个阶段,是不是镜像会越小?”
正解最终镜像大小只取决于最后一个阶段复制了什么,阶段数量多不一定小,反而会让构建依赖关系混乱、缓存更难维护。最佳实践是阶段要少而清晰:一个 builder 负责编译,必要时一个 intermediate 阶段负责整合产物,一个 runtime 阶段负责运行。多不一定干净,精准才叫干净。
为何常见:把“多阶段”误理解为“多用几段”,忽略了它的本质是产物隔离。
面试题
Q1. 多阶段构建最主要的好处是什么?
- A.可以让容器运行速度更快
- B.可以减小最终镜像体积← 正确答案
- C.必须增加构建阶段数量
- D.无需再指定基础镜像
答案:B。多阶段构建的核心是只把产物带进最终镜像,从而减小体积;容器运行速度不一定直接提升,阶段数量增加也不是目的,更不能免除基础镜像。好比外卖箱变小是因为只装便当,而不是因为骑手换了电动车。
Q2. FROM scratch 的含义是什么?
- A.使用一个空的基础镜像← 正确答案
- B.清空当前已有容器
- C.跳过所有构建步骤
- D.自动清理磁盘缓存
答案:A。scratch 是 Docker 提供的空镜像,里面没有任何文件和 shell。它不代表清空已有容器,也不等于跳过构建或清理磁盘。就像一片荷叶包饭,荷叶本身不提供任何配菜,只提供一个“载体”。
Q3. 多阶段构建中,从 builder 阶段拷贝产物到最终阶段,应该用哪个指令?
- A.COPY --from=builder /app/server /server← 正确答案
- B.ADD --stage=builder /app/server /server
- C.RUN --from=builder cp /app/server
- D.FROM builder COPY /app/server
答案:A。COPY --from=阶段名 是跨阶段拷贝产物的标准写法。ADD --stage、RUN --from=cp 都不是有效语法,FROM builder 是重新指定基础阶段而不是拷贝文件。这就像出餐窗口只认“便当传递单”,而不是把整个后厨搬过来。
Q4. 下列哪些做法能有效减小最终镜像体积?
- A.使用 scratch 作为运行基础镜像← 正确答案
- B.把依赖安装命令放在代码复制之前
- C.把安装过程拆成多个 RUN 指令
- D.只把构建产物复制到运行阶段← 正确答案
答案:AD。scratch 和“只复制产物”都是多阶段构建瘦身的直接手段;把依赖安装放在代码复制之前主要优化缓存,拆分 RUN 反而可能增加层数。前者像换小饭盒,后者像调整做菜顺序,只有前两者直接决定盒子里装什么。
Q5. Docker 构建上下文的大小取决于什么?
- A.运行容器时挂载卷的大小
- B.传给守护进程的文件总量← 正确答案
- C.基础镜像的压缩层数量
- D.最终镜像 COPY 的文件数
答案:B。构建上下文是 Docker CLI 发送给 daemon 的目录压缩包,大小取决于实际发送的文件总量。挂载卷、基础镜像层数和最终 COPY 文件数都不会直接决定上下文体积。就像进货卡车装多少菜,取决于你往卡车上放了多少菜,而不是厨房里有多少锅。
Q6. .dockerignore 文件的主要作用有哪些?
- A.减小构建上下文传输体积← 正确答案
- B.避免敏感文件被 COPY 进镜像← 正确答案
- C.自动压缩基础镜像的体积
- D.让所有 RUN 指令并行执行
答案:AB。.dockerignore 可以排除文件和目录,从而减小发送给 daemon 的体积,也能避免这些文件被 COPY . . 带进镜像。它不能压缩基础镜像,也不能并行执行 RUN 指令。它像一个门卫,只决定谁不能进后厨,不改菜谱,也不加速做菜。
Q7. 传统 Dockerfile 中执行 RUN rm -rf /var/cache 后,为什么镜像体积没有变小?
- A.删除行为不会被记录到层
- B.旧层仍在,删除只是新增层← 正确答案
- C.镜像层只增不减不能读取
- D.Docker 会直接忽略 rm 指令
答案:B。Docker 的层是只读叠加,删除文件只是在新层写入“已删除”标记,旧层文件依然可以被提取。所以镜像不会变小,反而多了一层。好比你在外卖盒上贴了一张“已倒掉垃圾”的标签,但垃圾还在盒子底部。
Q8. CI 中使用 trivy image --exit-code 1 --severity HIGH 的主要作用是什么?
- A.扫描到高危漏洞时让流水线失败← 正确答案
- B.自动修复所有高危镜像漏洞
- C.把镜像推送到仓库并打上标签
- D.直接删除镜像中的高危文件
答案:A。--exit-code 1 表示发现对应严重等级的漏洞时返回非 0 退出码,从而阻止 CI 继续。它不会修复漏洞、推送镜像或删除文件。这就像安检发现管制刀具后拒绝放行,而不是帮你把刀磨得更快。
Q9. 关于多阶段构建缓存优化,下列说法正确的是?
- A.不常变的依赖安装应放在 Dockerfile 前部← 正确答案
- B.先 COPY 依赖文件再 RUN 下载更容易命中缓存← 正确答案
- C.使用挂载缓存可缓存包管理器下载文件← 正确答案
- D.只要基础镜像相同后续所有层必然命中缓存
答案:ABC。把不常变的依赖层前置,可以让源码变化时依赖层依然命中缓存;RUN --mount=type=cache 能缓存包下载。D 错在“必然”,只要指令文本或上下文文件变了,缓存就会失效。就像老汤配方一变,锅就得重洗重熬。
Q10. 多阶段构建后,第一阶段镜像不在最终镜像中,但仍占用磁盘空间。下列哪项能主动回收这部分空间?
- A.重新执行 docker build 后自动回收
- B.执行 docker builder prune 清理缓存← 正确答案
- C.修改 Dockerfile 阶段名后自动清理
- D.删除最终镜像即可连带清理缓存
答案:B。第一阶段层会保留在本地 build cache 中,需要 docker builder prune 这类命令主动清理。重新 build、改阶段名、删除最终镜像都不会自动回收 build cache。就像备餐间的旧灶台还堆在仓库里,不叫人来搬走,它不会自己消失。