想象你刚买下一套新房。装修队进驻后,客厅堆满水泥袋、油漆桶、电钻、切割机,墙上还贴着施工图纸。终于装修完工了,你会把电钻留在茶几上吗?当然不会——你把成品家具搬进新家,装修工具通通撤走。Docker 镜像构建也是这个道理:编译一个 Go 程序需要完整的编译工具链,但程序跑起来只需要那个可执行文件。传统的单一阶段构建,就像把整个装修工地原封不动搬进新家,镜像动辄几百 MB;而多阶段构建,是让你在 Dockerfile 里写出多个“工棚”(构建阶段),每个阶段负责不同的工序,最后只把“成品”搬进真正入住的小房子。本文就用装修这条主线,讲透多阶段构建的原理、阶段命名、依赖管理、缓存策略、多语言实战、安全加固与性能调优。

岗位关联

多阶段构建是容器化镜像优化的核心技能。掌握它,你就能为 AI 推理服务交付更小、更快、更安全的镜像,提升部署效率,展现工程化的硬实力。

目录
  1. 可视化演示
  2. 基础原理——装修和入住,分开进行
  3. 阶段命名与引用——给工棚挂上名牌
  4. 构建依赖管理——工具箱只留在工棚
  5. 缓存利用策略——趁手的工具先摆好
  6. 多语言实践——不同工种的工具箱
  7. 安全加固实践——不把危险工具留在屋里
  8. 性能调优技巧——装修队并行施工
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

对比单阶段与多阶段Docker镜像构建,展示分层缓存复用与攻击面缩减效果,含构建流程与安全指标可视化。

本节配有交互动效(进入视口后自动加载,约 3KB)。

单阶段构建:镜像臃肿 编译器/依赖/源码全部打包 镜像体积达850M+ size = base + deps + build + src 单阶段将构建工具和源码残留进最终镜像,体积臃肿且攻击面大。

基础原理——装修和入住,分开进行

你请装修队给新家施工,客厅里先堆满水泥袋、油漆桶、电钻和图纸。等装修完毕,你最想做的当然是请装修队带着工具撤场,只把漂亮的成品留下来。传统 Docker 单阶段构建恰恰相反:它在一个基础镜像里装好编译器、拉取源码、生成产物,整个“工地”原封不动成了你的“家”。这就是搬家时把所有工具和建材都搬进客厅的尴尬。

多阶段构建打破了这种“一个 Dockerfile 对应一个环境”的思维。你可以在同一个 Dockerfile 里写多个 FROM 指令,每个 FROM 开启一个独立的构建阶段。前面的阶段负责编译、打包、生成安装文件,可以看作装修工棚;最后一个阶段只负责“入住”——把产物抄过来,配上最小化的运行依赖。

最终镜像的体积可以用这样一个关系表示:

所以,多阶段构建不是魔法,它只是让“装修”和“入住”各归各位。装修队在工棚里干活,新家只接收成品,镜像自然轻薄。

Vfinal=Vartifact+Vruntime=Vsingle−Vtoolchain−VsourceV_{\text{final}} = V_{\text{artifact}} + V_{\text{runtime}} = V_{\text{single}} - V_{\text{toolchain}} - V_{\text{source}}
  1. 最终镜像体积$V_{\text{final}}$ 表示多阶段构建后真正交付的镜像大小,它只包含“搬进新家”的部分。
  2. 产物加运行环境$V_{\text{artifact}} + V_{\text{runtime}}$ 是最终镜像的两块内容——编译出的可执行文件,以及运行它所需的最小系统库。
  3. 与单阶段镜像的关系$V_{\text{single}} - V_{\text{toolchain}} - V_{\text{source}}$ 说明单阶段镜像里有大量的工具链和源码是运行时根本用不到的,多阶段构建只是把它们从“新家”里请了出去。
dockerfile13 行
# 阶段一:装修工棚(构建环境)FROM golang:1.21 AS builderWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -o /app/server # 阶段二:正式入住(运行环境)FROM debian:12-slimWORKDIR /optCOPY --from=builder /app/server /opt/serverENTRYPOINT ["/opt/server"]

阶段命名与引用——给工棚挂上名牌

装修现场如果同时有三个工棚,你喊“把客厅吊灯递过来”,总比“东边第二个蓝顶棚里的纸箱”要靠谱得多。Docker 多阶段构建里的阶段也一样:每个 FROM 都可以用 AS 起一个语义化的名字,之后 COPY、构建指令想引用它时,只需叫名字。

这里 build 阶段先完成 Node 项目的依赖安装和打包,随后 server 阶段用 COPY --from=build 直接把构建产物(静态文件)抄进 nginx 的目录。整个流程可以浓缩成一条产物链:

如果你只想单查 builder 阶段的产物,不必构建完整镜像,用 docker build --target=builder -t debug . 就能精准停在某个工棚入口,像只验收装修到一半的房子。

给阶段起好名字,既让 Dockerfile 可读,也让后续维护者一眼看懂“哪件家具是从哪个工棚搬来的”。名字,就是多阶段构建的指路牌。

FROM  golang→ASbuilder→COPY –from=builderfinal\text{FROM}\;golang \xrightarrow{\text{AS}} \text{builder} \xrightarrow{\text{COPY --from=builder}} \text{final}
  1. 阶段起点$\text{FROM}\;golang$ 声明构建从 Go 基础镜像开始,这是工棚的地基。
  2. 语义命名$\xrightarrow{\text{AS}}$ 通过 AS 关键字把阶段命名为 builder,相当于给工棚挂上名牌。
  3. 产物搬运$\xrightarrow{\text{COPY --from=builder}}$ 最后从名为 builder 的工棚里取出成品,送进 final 运行环境。
dockerfile9 行
FROM node:20 AS buildWORKDIR /appCOPY package.json ./RUN npm ciCOPY . .RUN npm run build FROM nginx:alpine AS serverCOPY --from=build /app/dist /usr/share/nginx/html

构建依赖管理——工具箱只留在工棚

装修时,瓦刀、电钻、切割机都是必需品;可入住后,你只需要毛巾、牙刷和锅碗瓢盆。构建依赖与运行依赖天然是不同的集合——编译一个 Python 扩展需要 GCC、Python 头文件;但运行编译好的 .so 只需要系统动态库和 Python 解释器。

多阶段构建最擅长做这种“工具箱隔离”。在 builder 阶段,你想装多少编译依赖都行,反正这些工具不会进入最终镜像;到了 final 阶段,只装运行所需的最小依赖。一个典型的 Python 项目:

数学上,构建期与运行期的依赖集合应该是互斥的:

ARG 与 ENV 是这段“施工管理”中的搭档:ARG 只在构建期生效,传参给 go build -ldflags "-X main.version=${VERSION}";ENV 则固化到最终镜像,供运行时读取。区分好它们,就像分清楚施工图纸和入住使用手册。

把工具箱留在工棚,新家自然干净。多阶段构建让你理直气壮地对编译器说:你的使命到工棚门口就结束了。

Dbuild∩Druntime=∅D_{\text{build}} \cap D_{\text{runtime}} = \emptyset
  1. 构建期依赖$D_{\text{build}}$ 是编译阶段需要的全部软件包集合,例如 GCC、make、Python 头文件,相当于装修工具。
  2. 运行期依赖$D_{\text{runtime}}$ 是程序跑起来需要的软件包集合,例如 glibc、libssl,相当于生活日用品。
  3. 交集为空$\emptyset$ 表示两个集合理论上不该有交集,工具不该留在生活区。多阶段构建正是用物理隔离保证了这个空集。
dockerfile8 行
FROM python:3.12-slim AS builderRUN apt-get update && apt-get install -y build-essentialCOPY requirements.txt ./RUN pip wheel --wheel-dir=/wheels -r requirements.txt FROM python:3.12-slimCOPY --from=builder /wheels /wheelsRUN pip install --no-index --find-links=/wheels -r requirements.txt

缓存利用策略——趁手的工具先摆好

装修入场时,如果每次改动一面墙的图纸,就要把整个工具箱重新翻一遍、所有材料重新搬一次,那工期就注定了要崩溃。聪明的师傅会先把常用的工具和材料放在顺手的位置,再开始动会频繁修改的部分。Docker 镜像的层缓存机制正是如此。

Docker 的每条指令都会生成一层。某层内容没变,构建时就能直接从缓存复用;一旦某层变了,它之后的所有层都得重建。所以 Dockerfile 的指令顺序要遵循“先放不变的依赖,再放常改的源码”:

构建总耗时可以抽象为缓存命中层与未命中层耗时之和:

所以,把 COPY requirements.txt 和 RUN pip install 放在 COPY . . 之前,就是让“常驻工具箱”先就位;而把易变的源码放在最后,相当于只让“图纸”频繁替换。缓存策略的核心,是让变更局部化,别让一次小改动推倒整面墙。

T=Thit+Tmiss=∑i∈Hti+∑j∈MtjT = T_{\text{hit}} + T_{\text{miss}} = \sum_{i \in \mathcal{H}} t_i + \sum_{j \in \mathcal{M}} t_j
  1. 总耗时$T$ 是执行一次 docker build 的全部时间,等于从缓存里取层的耗时加上真正重建层的耗时。
  2. 命中部分$\sum_{i \in \mathcal{H}} t_i$ 表示所有缓存命中层的时间累加,$\mathcal{H}$ 是命中层的集合——这些层像“拆开即用的成品工具”,秒级复用。
  3. 未命中部分$\sum_{j \in \mathcal{M}} t_j$ 表示需要重新构建的层,$\mathcal{M}$ 是未命中层的集合。一旦源码层变化,它和后续所有层都要从零开始。
dockerfile8 行
FROM node:20-alpine AS buildWORKDIR /app# 依赖清单先拷贝并安装 —— 这层不常变COPY package.json package-lock.json ./RUN npm ci# 源码最后拷贝 —— 这层天天变COPY . .RUN npm run build

多语言实践——不同工种的工具箱

装修队里,木工用的锯子、水电工用的热熔机、油漆工的滚刷各不相同。Go、Python、Node 这三个主流语言,由于编译特性和依赖模型的差异,多阶段构建的“工具箱”也各自不同。

Go 是最省心的“成品家具”,能静态编译。在构建阶段设置 CGO_ENABLED=0,生成的二进制不依赖任何动态库,几乎可以放进“空房间”(scratch 基础镜像):

Python 则是“需要自带电力系统的精装房”——它必须带着解释器和一堆依赖。pipeline 的关键是把依赖集中打包:

Node 介于两者之间:构建产物是静态文件或打包好的 JS,最终镜像可以用 nginx 或轻量 node-alpine 承载,关键是构建阶段用 npm ci --only=production 避免安装开发依赖。

三种语言的镜像体积构成可以写成:

理解了各路工具的脾气,就能为每种语言定制最贴合的施工方案。多阶段构建不是一套模板,而是一套“按工种分工”的方法论。

VGo≈Vbinary,VPython=Vinterpreter+Vdeps,VNode≈Vruntime+VmodulesV_{\text{Go}} \approx V_{\text{binary}}, \quad V_{\text{Python}} = V_{\text{interpreter}} + V_{\text{deps}}, \quad V_{\text{Node}} \approx V_{\text{runtime}} + V_{\text{modules}}
  1. Go 的体积$V_{\text{Go}} \approx V_{\text{binary}}$ 指静态编译后镜像几乎就等于二进制本身的大小,没有运行环境负担。
  2. Python 的体积$V_{\text{Python}} = V_{\text{interpreter}} + V_{\text{deps}}$ 指它必须带上解释器(“发电机”)和第三方库(“电线”),所以体积总是有下限。
  3. Node 的体积$V_{\text{Node}} \approx V_{\text{runtime}} + V_{\text{modules}}$ 指它需要 Node 运行时加 node_modules,但可以舍弃开发依赖和源代码。
dockerfile10 行
FROM golang:1.21 AS buildWORKDIR /appCOPY go.mod ./RUN go mod downloadCOPY . .RUN CGO_ENABLED=0 go build -o server . FROM scratchCOPY --from=build /app/server /serverENTRYPOINT ["/server"]
dockerfile11 行
FROM python:3.12-slim AS builderWORKDIR /appRUN pip wheel --wheel-dir=/wheels -r requirements.txtCOPY app.py ./ FROM python:3.12-slimWORKDIR /appCOPY --from=builder /app/app.py ./COPY --from=builder /wheels /wheelsRUN pip install --no-index --find-links=/wheels -r requirements.txtCMD ["python", "app.py"]

安全加固实践——不把危险工具留在屋里

装修完工后,如果把电钻、切割机、油漆稀释剂都留在家里,一旦有人破门而入,这些工具就成了顺手可用的凶器。镜像也一样:编译器、包管理器是攻击者最爱的“武器库”。它们平时不说话,但一旦容器被攻破,攻击者就能借助 apt、pip、gcc 下载恶意软件、编译后门。

多阶段构建天然支持“危险工具不进屋”:编译工具只存在于 builder 阶段,最终镜像里只剩运行产物。在此基础上,再叠加几层加固措施——选择尽可能小的基础镜像、用非 root 用户运行、移除包管理器:

攻击面的大小与镜像中无关组件的数量直接相关:

安全是多阶段构建附赠的最大红利之一:不是“顺便瘦身”,而是“刻意断舍离”。把危险工具留在工棚,新家才能住得安心。

Sattack=∑i=1nCi,n→min⁡⇒Sattack→min⁡S_{\text{attack}} = \sum_{i=1}^{n} C_i, \quad n \rightarrow \min \Rightarrow S_{\text{attack}} \rightarrow \min
  1. 攻击面$S_{\text{attack}}$ 表示镜像被攻破后可供利用的表面积,单位是“可被利用的组件数量”。
  2. 组件贡献$C_i$ 是第 $i$ 个组件带来的可被利用的攻击渠道,例如一个包管理器就是一个高危渠道。
  3. 最小化原则$n \rightarrow \min$ 表示镜像中的组件数越少越好,多阶段构建移除编译器、包管理器,正是从源头减少 $n$ 的值。
dockerfile9 行
FROM golang:1.21 AS builderWORKDIR /appCOPY . .RUN CGO_ENABLED=0 go build -o server . FROM gcr.io/distroless/static-debian12USER nonroot:nonrootCOPY --from=builder /app/server /serverENTRYPOINT ["/server"]

性能调优技巧——装修队并行施工

独自扛着所有工具按顺序施工,和分派水电、木工、油漆三个班组分区并行,速度天差地别。Docker 的 BuildKit 构建引擎(新版 Docker 默认开启)正是这种“并行装修队”——它能识别出无依赖关系的阶段,让它们同时构建。

性能调优不只是“缩小体积”,还包括“缩短时长”。几个实用技巧:

  • 开启 BuildKit:DOCKER_BUILDKIT=1 docker build(新版默认开启),自动跳过最终镜像不需要的阶段。
  • 阶段复用:多个服务共用一个基础构建阶段,比如先在 builder 里构建公共依赖,再由各子阶段各自 COPY 产物。
  • 定向构建调试:docker build --target=builder 只构建某个阶段,避免全量构建浪费时间。
  • 体积对比验证:用 docker history <image> 检查每层大小,确认是否有意外塞入的巨型层。

并行构建的收益可以用一个简单的模型表达:

施工管理的最高境界,是既快又省。多阶段构建的并行能力配合缓存策略,能让 CI 流水线从“一条长队”变成“几路并进”,为团队省下真金白银。

Tparallel=max⁡(Ts1,Ts2,…,Tsn)≪∑i=1nTsiT_{\text{parallel}} = \max\left(T_{s_1}, T_{s_2}, \ldots, T_{s_n}\right) \ll \sum_{i=1}^{n} T_{s_i}
  1. 并行总耗时$T_{\text{parallel}}$ 表示多个阶段同时构建时,总耗时只取决于最慢的那个阶段。
  2. 取最大值$\max\left(T_{s_1}, \ldots, T_{s_n}\right)$ 是各阶段耗时的最大值,对应“水电班组还没干完,油漆组就得等”的场景。
  3. 与串行对比$\sum_{i=1}^{n} T_{s_i}$ 是各阶段串行耗时之和,两者的差值 $\ll$ 就是并行化省下的时间。

AI 应用场景

AI 推理服务镜像优化

阶段:模型部署 / 服务打包

解决的问题:传统的 AI 推理镜像从 pytorch/pytorch 起步,动辄 3-5GB。这个体积里有训练时才需要的 CUDA 工具链、编译器和各类训练库;而推理阶段只需要 torch runtime + 模型权重。直接分发大镜像,拉取慢、启动慢、安全风险高。

具体案例:某团队将推理 Dockerfile 拆成两段:第一段用 CUDA 构建环境编译自定义算子,第二段用 nvidia/cuda:12.1-runtime 作为底座,只 COPY 编译好的 .so 和模型文件,镜像从 4.2GB 降到 680MB,GPU 节点扩容时间缩短 60%。

MLOps 流水线镜像供应链

阶段:训练任务与数据预处理

解决的问题:训练镜像往往需要频繁迭代,如果每次改一行数据清洗代码就全量重装依赖,代价高昂。多阶段构建的“依赖前置 + 缓存利用”策略,让依赖层缓存永不失效,源码变更只重建最后几层。

具体案例:一个特征工程服务,构建阶段用 python:3.12-slim 安装 pandas、pyarrow 等编译依赖;运行阶段复用相同基础镜像但只 COPY 打包好的 wheel 和脚本,配合 BuildKit 缓存,每次提交的构建时间从 8 分钟压到 50 秒。

模型服务安全合规交付

阶段:模型上线 / 安全审查

解决的问题:金融、医疗机构的容器安全扫描要求镜像中不得出现包管理器或编译器(CVE 高危来源)。审计工具会标记 apt-get、pip、gcc 等潜在风险。

具体案例:某银行 AI 风控系统使用 distroless 基础镜像 + 多阶段构建交付推理服务,Trivy 扫描高危漏洞数为 0,快速通过安全合规评审,省去逐个 CVE 修复的沟通成本。

Python 演示

python63 行
"""多阶段构建体积与缓存模拟器""" class Layer:    """模拟 Docker 镜像中的一个层"""     def __init__(self, name, size_mb, runtime=False):        self.name = name        self.size_mb = size_mb        self.runtime = runtime            # 是否进入最终运行镜像  BUILDER = [    Layer('基础镜像', 150),    Layer('编译工具链', 280),    Layer('源码与依赖', 12),    Layer('编译产物', 8, runtime=True),] RUNTIME = [    Layer('轻量运行基础', 25, runtime=True),    Layer('编译产物', 8, runtime=True),]  def single_stage_size():    """单阶段:所有层都会进入最终镜像"""    return sum(l.size_mb for l in BUILDER)  def multi_stage_size():    """多阶段:只保留运行阶段里标记 runtime 的层"""    return sum(l.size_mb for l in RUNTIME if l.runtime)  def cache_sim(layers, changed):    """模拟层缓存:changed 层出现时,它及之后都要重建"""    hit = 0    for name in layers:        if name == changed:            break        hit += 1    return hit, len(layers) - hit  def main():    s = single_stage_size()    m = multi_stage_size()    print('=== 体积对比 ===')    print(f'单阶段镜像: {s} MB')    print(f'多阶段镜像: {m} MB')    print(f'优化率: {(1 - m / s) * 100:.1f}%\n')     print('=== 缓存对比(改一行源码) ===')    good = ['base', 'install deps', 'copy src', 'build']    bad = ['base', 'copy src', 'install deps', 'build']    hit, rebuilt = cache_sim(good, 'copy src')    print(f'依赖前置策略: 命中 {hit} 层, 重建 {rebuilt} 层')    hit, rebuilt = cache_sim(bad, 'copy src')    print(f'源码前置策略: 命中 {hit} 层, 重建 {rebuilt} 层')  if __name__ == '__main__':    main()
目的:用两段模拟实验直观演示多阶段构建带来的体积收益与缓存优化收益。
  1. class Layer:用类封装镜像层,一个层的 name、size_mb、runtime 属性分别对应层名、体积、是否要进入最终镜像。
  2. runtime=False默认值表示大多数层(工具链、源码)不该进入最终镜像,只有显式标记的产物才被保留。
  3. BUILDER = [...]定义“工棚”阶段的层,包含基础镜像、工具链、源码和产物。
  4. RUNTIME = [...]定义“新家”阶段的层,只有轻量运行基础和拷贝进来的产物。
  5. multi_stage_size()` 中的 `if l.runtime这是多阶段构建的灵魂:过滤掉所有不需要的层,只统计真正进入最终镜像的体积。
  6. cache_sim(good, 'copy src')模拟改一行源码后哪些层命中缓存、哪些层需要重建,展示依赖前置策略的价值。
  7. main()依次打印体积对比和缓存对比结果,像装修验收时同时检查“省了多少材料”和“改图纸时返工多少”。

常见误区

误区我只要在 Dockerfile 里多写几个 FROM,镜像体积就能自动变小。FROM 写得越多,镜像越小。

正解多阶段构建只是把“装修”和“入住”隔开的手段,真正让镜像变小的是“最后一个阶段只保留产物并选用小基础镜像”。如果最后一个阶段仍然基于 golang:1.21 或重复安装了一堆编译工具,镜像照样臃肿。FROM 的数量本身不等于优化。

为何常见:教程里往往同时展示“写两个 FROM”和“镜像变小”两个现象,初学者容易把先后关系误当成因果关系。

误区既然 Dockerfile 是“从下往上”构建的,我用了 `FROM golang` 开头,后面就只能继续用 golang 系列镜像,换别的镜像会乱套。

正解多阶段构建的每个 FROM 都是全新的构建环境,彼此之间没有继承关系。你可以第一段用 golang 编译,第二段用 alpine 或 distroless 运行,只要通过 COPY --from 把产物拿过来即可。阶段之间传递的是文件,而不是环境。

为何常见:把多阶段误认为是“一条流水线里叠加指令”,忽略了它其实是“多个独立容器按需搬运文件”。

误区编译器、pip、apt 这些装的依赖放在最终镜像里又不会主动执行,多几百 MB 而已,没什么大不了。

正解这些组件实实在在地增加攻击面:一旦应用被攻破,攻击者可以直接用 apt/pip/gcc 下载和编译恶意程序;安全扫描器也会把包管理器列为高危项。多阶段构建剔除编译工具,既减小体积又提高安全性,是一举两得。

为何常见:本地开发习惯被搬到了生产环境——开发机上装工具是方便,生产镜像里装工具则是隐患。

误区反正都是 COPY 和 RUN,顺序无所谓。把整个项目先拷进去,再安装依赖,每次构建最多重新装一次依赖,能有多大区别?

正解Docker 层缓存的规则是“某层变更则后续全部失效”。把 COPY . . 放前面,任何一行源码改动都会让后面的依赖安装层缓存全部失效,每次都重新下载编译依赖。正确做法是先 COPY 依赖清单、安装依赖,再 COPY 源码,让“常驻工具箱”不因图纸改动而返工。

为何常见:不理解层缓存是按“层顺序”生效的机制,凭直觉认为“先拷贝完整代码再安装”才是完整构建。

误区用了多阶段构建,就代表镜像已经优化到位了,不需要再看基础镜像和包管理。

正解多阶段构建是“手段”而非“终点”。如果最终阶段选用 python:3.12 而不是 python:3.12-slim,或安装依赖时没有清理 pip 缓存、.pyc 文件,体积仍然偏大。多阶段构建后还应检查基础镜像大小、依赖清理情况和层体积分布,才能逼近理论最小体积。

为何常见:把优化“功劳”全部归于某个单一技巧,忽略整体工程的系统性。

面试题

Q1. 多阶段构建中,最终镜像体积主要由哪部分决定?

单选题 · easy

  • A.第一个基础镜像大小
  • B.最终阶段保留的层← 正确答案
  • C.各阶段体积之和
  • D.构建缓存的数量

答案:B。最终镜像只包含最后一个阶段保留的层和通过 COPY --from 引入的产物,前面的阶段都是“工棚”,不影响交付体积。选择 B 是因为它准确指向了多阶段构建的产物链终点;A、C、D 都与最终交付镜像无关。

Q2. 在 Dockerfile 中给构建阶段起名的关键字是什么?

单选题 · easy

  • A.NAME 指令关键字
  • B.AS 阶段命名关键字← 正确答案
  • C.TAG 镜像标签指令
  • D.LABEL 元数据标注

答案:B。FROM xxx AS builder 中的 AS 就是给阶段命名的关键字,之后可以用 COPY --from=builder 来引用。NAME/TAG/LABEL 分别是别的作用域下的关键字,不起阶段命名作用。

Q3. 从上一个阶段复制文件到当前阶段应该使用哪个指令?

单选题 · mid

  • A.ADD --from=builder
  • B.COPY --from=builder← 正确答案
  • C.RUN --source=builder
  • D.MOUNT --stage=builder

答案:B。COPY --from=builder /path /dest 是跨阶段拷贝文件的标准写法。ADD 虽然也能拷贝文件,但 --from 参数是 COPY 指令多阶段构建的标准能力;RUN 和 MOUNT 没有跨阶段拷贝语义。

Q4. 只想单独构建名为 builder 的阶段用于调试,应使用哪个参数?

单选题 · mid

  • A.--target=builder← 正确答案
  • B.--stage=builder
  • C.--only=builder
  • D.--focus=builder

答案:A。docker build --target=builder 可以精准停在 builder 阶段构建完的那一刻,像只验收装修到一半的房子。其余参数均不是 Docker 官方支持的构建定位参数。

Q5. 关于 Docker 层缓存,以下哪项描述正确?

单选题 · mid

  • A.改变一层只会影响该层
  • B.改变一层会使后续层缓存失效← 正确答案
  • C.镜像层缓存永远不会失效
  • D.只有基础镜像才有层缓存

答案:B。Docker 按层顺序构建,某一层内容变化,它之后的所有层无论内容是否变化都会因父层指纹改变而失效。这就像装修时改了一面墙的图纸,后面的工序都得跟着重做。

Q6. 要让 Go 项目获得最小体积的最终镜像,最佳组合是?

单选题 · hard

  • A.golang 基础镜像配合 go run
  • B.CGO 关闭加 scratch 镜像← 正确答案
  • C.ubuntu 里手动安装 Go
  • D.python 运行时打包 Go

答案:B。CGO_ENABLED=0 让 Go 程序静态编译,不依赖动态库,再加上 scratch 空基础镜像,最终二进制就是镜像的全部内容。A、C 会把工具链带进镜像,D 是明显错配。

Q7. 下方 Dockerfile 中,最终镜像包含哪个文件?

单选题 · mid

  • A.只包含编译用的源码
  • B.只包含拷贝的 /opt/app← 正确答案
  • C.包含 Golang 工具链
  • D.包含 Alpine 包管理器

答案:B。最终阶段基于 alpine,只执行了 COPY --from=builder /app /opt/app,因此最终镜像只包含 /opt/app 和 alpine 自身基础文件,Golang 工具链及源码都在 builder 阶段被丢弃。

Q8. 多阶段构建能够带来哪些收益?

多选题 · mid

  • A.减小最终镜像的体积← 正确答案
  • B.降低运行时的攻击面← 正确答案
  • C.提升构建的缓存利用率← 正确答案
  • D.让源码进入最终镜像

答案:ABC。多阶段构建让工具链和源码留在构建阶段,同时缩小体积、降低攻击面、配合依赖前置还能提升缓存命中率。D 恰好与多阶段构建的初衷相反,源码应当尽可能留在构建阶段。

Q9. 以下哪些措施能进一步优化多阶段构建的镜像?

多选题 · hard

  • A.依赖清单先 COPY 再安装← 正确答案
  • B.使用 slim 或 distroless← 正确答案
  • C.在最终阶段安装编译器
  • D.构建后清理包管理器缓存← 正确答案

答案:ABD。依赖前置保持缓存命中;slim/distroless 基础镜像直接减小体积;清理 apt/pip 缓存避免垃圾层。C 在最终阶段安装编译器会显著增大体积并引入安全隐患,是错误做法。

Q10. 关于 BuildKit 并行构建,下列说法正确的有?

多选题 · extreme

  • A.阶段间无依赖可并行执行← 正确答案
  • B.BuildKit 可跳过无用阶段← 正确答案
  • C.所有阶段必定会并行执行
  • D.需要较新的 Docker 版本支持← 正确答案

答案:ABD。BuildKit 会在构建图上做依赖分析,没有相互依赖的阶段可以并行跑,也能跳过最终镜像不需要的阶段;但它不是“必定并行”,只有满足依赖条件时才并行。此外 BuildKit 需要新版 Docker 支持,旧版本需手动开启。