Docker 工程化实践系列 · 第 4 篇
引言:构建快了,但镜像还是那么大
上一篇文章我们解决了”重复构建慢”的问题。通过理解 Build Cache 的工作机制,我们把 Dockerfile 中的指令重新排序——依赖文件放前面,源码放后面——让日常的业务代码修改不再触发依赖重装。现在每次构建只需要十几秒,而不是几分钟。
但一个新的问题冒出来了:
docker imagesREPOSITORY SIZEfastify-api 1.5GB1.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 builderWORKDIR /appCOPY package.json pnpm-lock.yaml ./RUN corepack enableRUN pnpm installCOPY . .RUN pnpm build
# =====================# Production Stage(交付阶段——只"带出去"需要的)# =====================FROM node:22-slim AS productionWORKDIR /appCOPY package.json pnpm-lock.yaml ./RUN corepack enable \ && pnpm install --prodCOPY --from=builder /app/dist ./distEXPOSE 3000CMD ["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 里有两种依赖:dependencies(fastify、prisma、redis——运行时需要)和 devDependencies(typescript、eslint、jest——只有开发和构建时需要)。多阶段构建天然支持将它们分离: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 install → COPY . . 在后 → 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 健康检查、密钥不入镜像、版本固定和镜像安全扫描。
Some information may be outdated