LOADING
2324 words
12 minutes
Docker 工程化实践(四):Docker 多阶段构建与生产镜像优化

Docker 工程化实践系列 · 第 4 篇

引言:构建快了,但镜像还是那么大

上一篇文章我们解决了”重复构建慢”的问题。通过理解 Build Cache 的工作机制,我们把 Dockerfile 中的指令重新排序——依赖文件放前面,源码放后面——让日常的业务代码修改不再触发依赖重装。现在每次构建只需要十几秒,而不是几分钟。

但一个新的问题冒出来了:

docker images
REPOSITORY SIZE
fastify-api 1.5GB

1.5GB。构建是快了,但镜像本身依然巨大。你打开这个镜像看看里面有什么:TypeScript 编译器、ESLint、测试框架、项目源码、构建工具链——而你的生产服务器实际上只需要执行一条 node dist/server.js。生产环境仅仅需要 Node.js 运行时、编译产物和生产依赖,其余的所有东西都是多余的。

Build Cache 解决的是: 重复构建”慢”的问题 ↓ 但镜像本身体积没有变化

于是自然引出一个问题: 能不能只保留运行时需要的东西, 把构建过程中用到的工具全部丢掉?

这就是 Multi-stage Build(多阶段构建)要回答的问题。本文的核心只有一句话:多阶段构建的本质不是写多个 FROM,而是只复制最终产物——构建阶段是一个工厂,不是最终交付物。

一、多阶段构建真正解决了什么

1. 先看普通 Dockerfile 的问题

一份没有优化的 Node.js Dockerfile 通常是这样写的:

FROM node:22
COPY . .
RUN pnpm install
RUN pnpm build
Image

最终镜像里有什么?我们进入容器看一看:

/app
├── src/ ← 源码,生产不需要
├── dist/ ← 编译产物,需要
├── node_modules/ ← 全部依赖(含 dev),太大
├── package.json
├── tsconfig.json ← TypeScript 配置,生产不需要
├── eslint.config.js ← ESLint 配置,生产不需要
└── test/ ← 测试文件,生产不需要

结果就是:一个只需要跑 node dist/server.js 的运行时,却携带了整个开发工作站。

2. Multi-stage 的本质:只复制最终产物

多阶段构建把整个过程拆成两段:

普通构建: Multi-stage:
Builder Builder
源码 源码
↓ ↓
dist dist
↓ ↓
整个 Builder 发布 COPY --from=builder
(只拿走 dist)
Production
(只有 dist)

Builder 阶段拥有完整的工具链——TypeScript、ESLint、全部 node_modules——它负责把源码编译成 dist。但关键是:Builder 阶段不是为了发布而存在的,它只是一个”生产最终产物的工厂”。 真正发布出去的 Production 阶段,从一个全新的、干净的基础镜像开始,只安装生产依赖,然后从 Builder 里把 dist 复制过来。

用一句话概括 Multi-stage 的本质:

不是写多个 FROM,而是只复制最终产物。Builder 负责”造出来”,Production 负责”带出去”。

优化后

3. 新手最容易犯的错误:FROM builder

很多初学者理解了 Builder 的概念后,会写出这样的代码:

FROM node:22 AS builder
# ... 构建过程 ...
FROM builder # ❌ 从 builder 继承!

这样做等于完全没有使用多阶段构建。为什么?因为 FROM builder 会把 Builder 阶段的一切全部继承过来——源码、开发依赖、TypeScript、ESLint、构建工具——你只是换了个名字,内容一点没少:

FROM builder
继承了什么?
Builder 阶段全部内容:
├── src/ ← 源码,还在
├── node_modules/ ← 全部依赖(含 dev),还在
├── typescript ← 还在
├── eslint ← 还在
├── dist/ ← 编译产物
└── test/ ← 还在
等于:
没有 Multi-stage

正确的做法永远是 FROM node:22-slim——从一个干净的基础镜像重新开始,然后只从 Builder 复制你真正需要的东西。

二、多阶段构建的语法与执行过程

1. 一个完整的多阶段构建示例

把之前的单阶段 Dockerfile 改造为多阶段版本:

# =====================
# Build Stage(工厂阶段——负责"造出来")
# =====================
FROM node:22 AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable
RUN pnpm install
COPY . .
RUN pnpm build
# =====================
# Production Stage(交付阶段——只"带出去"需要的)
# =====================
FROM node:22-slim AS production
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable \
&& pnpm install --prod
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]

两个新语法:FROM node:22 AS builder 给构建阶段命名,COPY --from=builder /app/dist ./dist 从构建阶段精确复制需要的文件。

2. 执行流程

Docker 按顺序执行 Build Stage:安装依赖 → 复制源码 → 编译,生成 /app/dist。然后遇到第二个 FROM 指令时,Docker 不是基于 builder 继续构建,而是重新创建一个全新的、干净的镜像环境。在这个新环境中只安装生产依赖,再从 builder 那里把 dist 复制过来。

最终镜像里的内容:

/app
├── dist/ ← 从 builder 复制的编译产物
├── node_modules/ ← 仅生产依赖(pnpm install --prod)
├── package.json
└── pnpm-lock.yaml

对比一下,少了什么?没有 src/,没有 TypeScript,没有 ESLint,没有 test/,没有构建工具链。镜像从 1.5GB 降到 300MB,不是因为压缩了什么——是因为根本就没把这些东西放进去。

3. 生产依赖与开发依赖的分离

Node.js 项目的 package.json 里有两种依赖:dependenciesfastifyprismaredis——运行时需要)和 devDependenciestypescripteslintjest——只有开发和构建时需要)。多阶段构建天然支持将它们分离:Build Stage 里用 pnpm install 安装全部依赖用于编译,Production Stage 里用 pnpm install --prod 只安装运行时真正需要的包。

一个典型 TypeScript 项目的 devDependencies 体积(TypeScript 编译器本身就好几十 MB,加上类型定义、测试框架、lint 工具)往往比 dependencies 大得多。这 200MB+ 的开发和构建工具链,在生产镜像里一个都不需要。

4. Build Cache 在多阶段构建中的应用

多阶段构建中的 Build Stage 同样需要遵守缓存优化原则:COPY package.json 在前 → RUN pnpm installCOPY . . 在后 → RUN pnpm build。这个原则在第三篇已经详细展开,这里不再重复——只需要记住:让变化频率不同的文件进入不同的构建步骤,缓存才能高效工作。

整理的图片

三、核心对比图

下面这张图展示了普通 Dockerfile 和 Multi-stage Build 的本质区别:

左边是”把整个开发环境打包发布”,右边是”工厂造完,只带走产品”。这就是 Multi-stage Build 一图胜千言的解释。

四、常见错误与验证方法

错误一:FROM builder 继承构建阶段。 这是最常见的错误——在生产阶段写 FROM builder 而不是 FROM node:22-slim。结果是 Builder 中的所有内容(源码、开发依赖、构建工具)全部进入生产镜像,多阶段构建形同虚设。正确做法永远是从一个干净的基础镜像重新开始。

错误二:从本地复制 node_modules 本地可能运行在 Windows 或 macOS,容器内部是 Linux,不同平台的原生模块不兼容。应该让容器内部自行安装依赖。

错误三:在 Production Stage 中用 COPY . . 替代 COPY --from=builder 这会把整个项目目录(包括 src、test、配置文件)全部复制进生产镜像,失去了多阶段构建”只带走产物”的意义。

构建完成后,用 docker images 对比优化前后的镜像大小,用 docker history <image> 查看每层的大小分布,确认超大体积的层是否已经被裁剪掉。

五、总结

让我们把整个 Docker 工程化实践系列的主线串起来。这个系列每一篇都是围绕着同一个核心展开的——镜像层(Layer)

Dockerfile
Layer(第二篇:镜像分层的世界观)
Layer 可以被缓存 → Build Cache(第三篇)
解决:重复构建"慢"的问题
Layer 可以被裁剪 → Multi-stage Build(第四篇)
解决:最终镜像"大"的问题
Layer 可以被优化 → Image Optimization(第五篇)
解决:生产镜像"安全与可维护"的问题
Layer 可以被部署 → Docker Compose(第六篇)
解决:多容器"协同运行"的问题

第二篇建立的”镜像分层”是整个系列的基石。第三篇利用了”层可以复用”这个特性来加速构建,本篇利用了”层可以裁剪”这个特性来缩小镜像——Builder 阶段的层负责生产最终产物,但本身不会被发布;Production 阶段从一个干净的起点出发,只带走真正需要的那几层。同一个原理,不同的应用方向。

Docker Build Cache 解决了”重复构建慢”的问题,Multi-stage Build 解决了”最终镜像太大”的问题。掌握了这两项能力,你的镜像已经从”能跑”升级到了”又小又快”。但”小”和”快”还不是终点——下一篇将介绍如何让生产镜像更安全、更可维护:非 root 用户运行、HEALTHCHECK 健康检查、密钥不入镜像、版本固定和镜像安全扫描。

上一篇:Docker 工程化实践(三):Docker Build Cache 与构建优化

下一篇:Docker 工程化实践(五):Docker 镜像优化最佳实践

Docker 工程化实践(四):Docker 多阶段构建与生产镜像优化
/posts/2026-7-15/4-docker-工程化实践四docker-多阶段构建与生产镜像优化/
Author
Atopos
Published at
2026-07-15
License
CC BY-NC-SA 4.0

Some information may be outdated