Docker 多阶段构建与镜像瘦身实战:从构建加速到安全发布的完整方案
很多团队一开始用 Docker,都能很快把服务“跑起来”,但跑起来不等于“跑得好”。我见过不少镜像:
- 一个简单 Go 服务,镜像 800MB+
- Node.js 项目把
node_modules、构建缓存、测试文件一股脑打进去 - 生产镜像里还保留编译器、包管理器、shell 工具
- CI 每次构建都从零开始,慢得让人怀疑人生
- 镜像能跑,但安全扫描一堆高危漏洞
这些问题背后,其实都指向同一件事:镜像构建过程没有被设计过。
这篇文章我不打算只讲“什么是多阶段构建”,而是带你从一个常见项目出发,把 多阶段构建、镜像瘦身、构建加速、安全发布 串成一套能落地的方案。你看完之后,至少能做到三件事:
- 会写可维护的多阶段 Dockerfile
- 知道镜像为什么大、慢、不安全
- 能在 CI/CD 里落地一套更稳的发布流程
背景与问题
先看几个典型症状。
1. 镜像太大
镜像大通常不只是“占磁盘”,还会带来连锁问题:
- 拉取慢,部署慢
- 节点扩容时启动慢
- 仓库流量成本增加
- 安全扫描耗时更久
- 层里包含太多无关文件,攻击面变大
2. 构建太慢
常见原因有:
- Dockerfile 指令顺序不合理,导致缓存失效
COPY . .太早,任何代码变动都会触发依赖重装- 没有利用 BuildKit 缓存挂载
- CI 每次都是冷启动构建
3. 生产镜像不安全
如果构建镜像和运行镜像是同一个环境,就容易把这些东西一起带进生产:
- 编译器
- 调试工具
- 包管理器
- 源码
- 测试文件
- 凭证文件或误带的
.env
多阶段构建的意义,就是把“构建所需”和“运行所需”彻底分开。
前置知识与环境准备
建议你本地准备:
- Docker 20.10+
- 推荐启用 BuildKit
- 一个简单的 Node.js 项目
- 能使用
docker build,docker run,docker image ls
启用 BuildKit 的方式之一:
export DOCKER_BUILDKIT=1
如果你用 Docker Desktop,通常默认已经支持。
核心原理
什么是多阶段构建
多阶段构建的核心思路很简单:
在前面的阶段完成编译、打包、测试;在最后的阶段只保留运行时真正需要的产物。
比如一个前端或 Node.js 服务:
builder阶段:安装完整依赖、编译代码runner阶段:只复制编译结果和生产依赖
这样最终镜像里就不会包含构建工具链。
为什么它能瘦身
因为 Docker 镜像是分层的。你在某一层安装了大量构建工具,如果最终镜像还是基于那个层继续往下叠,那些内容就会一直存在。
而多阶段构建相当于:
- 前面阶段“随便装”
- 最后阶段“只拿结果,不拿过程”
为什么它能提升安全性
攻击面来自镜像里的内容。你去掉:
- gcc / make
- git / curl / bash
- 源码与测试文件
- 多余包管理工具
就等于减少了潜在利用入口。
一张图看懂多阶段构建流程
flowchart LR
A[源码目录] --> B[构建阶段 builder]
B --> C[安装依赖]
C --> D[编译/打包]
D --> E[产物 dist]
E --> F[运行阶段 runner]
F --> G[仅复制运行所需文件]
G --> H[生成最终生产镜像]
单阶段构建为什么容易出问题
先看一个“能用但不优雅”的单阶段 Dockerfile。
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个写法的问题很集中:
COPY . .太早,任何代码改动都会让依赖缓存失效npm install会装开发依赖- 构建工具和源码都留在最终镜像里
- 没有
.dockerignore时,连.git、日志、测试文件都可能被打进去
实战代码:从普通 Dockerfile 升级为多阶段构建
下面我用一个简单的 Node.js + TypeScript 服务做示例。目录结构大致如下:
demo-app/
├── src/
│ └── server.ts
├── package.json
├── package-lock.json
├── tsconfig.json
└── Dockerfile
示例应用代码
package.json
{
"name": "demo-app",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.7.4",
"typescript": "^5.6.2"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "src",
"strict": true
}
}
src/server.ts
import express from "express";
const app = express();
const port = process.env.PORT || 3000;
app.get("/", (_req, res) => {
res.json({
message: "hello docker multi-stage build"
});
});
app.listen(port, () => {
console.log(`server started on port ${port}`);
});
第一步:写一个基础版多阶段 Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个版本已经比单阶段构建好很多了:
- builder 负责编译
- runner 只保留运行依赖和编译结果
- 生产镜像里没有 TypeScript 源码和 devDependencies
构建与运行
docker build -t demo-app:basic .
docker run -p 3000:3000 demo-app:basic
访问:
curl http://localhost:3000/
预期输出:
{"message":"hello docker multi-stage build"}
第二步:进一步优化构建缓存
很多人已经用了多阶段构建,但构建依然慢,原因通常在于 缓存层设计不合理。
更合理的顺序
先复制依赖描述文件,再安装依赖,最后复制业务代码:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
为什么这样更快?
因为日常开发中,改动最频繁的是 src/,不是 package-lock.json。只要依赖文件没变,npm ci 这层就能复用缓存。
第三步:使用 BuildKit 缓存提升安装速度
如果 CI 或本地经常重新构建,npm ci 仍然会花不少时间。BuildKit 的缓存挂载就很有帮助。
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]
这类缓存的意义是:
- 即使镜像层缓存失效
- npm 包下载缓存仍然可复用
- 对 CI 场景尤其友好
第四步:进一步瘦身与安全加固
下面是我更推荐在生产里使用的一版。
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:20-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
&& npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
这里多做了几件事:
- 设置
NODE_ENV=production - 使用
npm ci --omit=dev仅安装生产依赖 - 切换为非 root 用户
node - 清理 npm cache
构建产物流转图
sequenceDiagram
participant Dev as 开发者
participant Builder as builder 阶段
participant Runner as runner 阶段
participant Registry as 镜像仓库
Dev->>Builder: 复制 package*.json
Builder->>Builder: npm ci
Dev->>Builder: 复制源码
Builder->>Builder: npm run build
Builder->>Runner: 复制 dist
Runner->>Runner: 安装生产依赖
Runner->>Registry: 推送最终镜像
.dockerignore 是瘦身的关键配角
很多时候,镜像过大不是 Dockerfile 本身的问题,而是构建上下文太脏。
建议至少加上:
node_modules
dist
.git
.gitignore
Dockerfile
*.log
coverage
.env
.env.*
npm-debug.log
为什么这很重要
执行 docker build . 时,当前目录会作为构建上下文发送给 Docker daemon。
如果没有 .dockerignore,哪怕 Dockerfile 最终没用到某些文件,它们也可能已经被传输了,拖慢构建。
这个点我以前真踩过:代码不大,但 .git 历史很重,构建上下文传了半天,最后发现 Dockerfile 一行都没引用它。
多阶段构建的常见模式
除了 Node.js,这个思路对很多语言都适用。
Go 应用
Go 特别适合多阶段构建,因为最终只需要一个二进制文件。
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app .
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /src/app ./app
EXPOSE 8080
CMD ["./app"]
如果编译为静态二进制,甚至可以考虑 scratch 作为最终镜像基底。
前端静态站点
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
这种场景非常经典:构建环境是 Node,运行环境却根本不需要 Node。
逐步验证清单
写完 Dockerfile 后,建议你按这个顺序验证,而不是“能跑就算了”。
1. 验证镜像大小
docker image ls | grep demo-app
2. 查看镜像层历史
docker history demo-app:basic
关注是否还有:
- 编译工具
- 多余文件复制
- 大层异常增长
3. 检查运行用户
docker run --rm demo-app:basic id
如果输出是 root,就说明还没降权运行。
4. 进入容器看实际内容
docker run --rm -it demo-app:basic sh
检查是否还残留:
src/- 测试目录
- 构建工具
- 私密配置文件
5. 验证服务可用性
docker run --rm -p 3000:3000 demo-app:basic
curl http://localhost:3000/
常见坑与排查
这部分很重要。多阶段构建不难学,但容易在细节上翻车。
坑 1:COPY . . 导致缓存频繁失效
现象
每次改一行业务代码,依赖都重新安装。
原因
依赖文件和源码一起复制,只要任意文件变化,对应层缓存就会失效。
解决
先复制依赖清单,再安装依赖,最后复制源码。
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
坑 2:builder 阶段能跑,runner 阶段启动失败
现象
容器启动时报错:
- 找不到模块
- 缺少配置文件
- 二进制无法执行
原因
你只复制了构建产物,但忘了复制运行时仍需要的文件,比如:
package.json- 配置文件
- 静态资源
- 动态库
解决
明确列出运行阶段真正需要的文件,不要想当然。
例如:
COPY package.json package-lock.json ./
COPY --from=builder /app/dist ./dist
COPY config ./config
坑 3:Alpine 镜像更小,但运行出错
现象
某些依赖在 alpine 下编译或运行异常。
原因
Alpine 使用 musl libc,而很多预编译依赖是基于 glibc 的。
Node、Python、Java 某些原生模块场景里比较常见。
解决建议
- 如果是纯 JS/纯 Go 场景,
alpine往往很好用 - 如果有原生依赖,优先验证兼容性
- 不要为了小几十 MB 强行上 Alpine,稳定性更重要
我个人的经验是:先确保稳定,再追求极限瘦身。
坑 4:删除文件不等于镜像层真的变小
现象
Dockerfile 里 RUN rm -rf xxx 了,但镜像还是很大。
原因
Docker 是分层存储。你在前一层加进去的东西,后一层删除,并不会抹掉前一层的体积。
错误示例
RUN apt-get update
RUN apt-get install -y build-essential
RUN make build
RUN apt-get remove -y build-essential
更好的方式
- 把临时内容放在同一个
RUN里处理 - 或者更直接:放到 builder 阶段,最终镜像根本不带这些层
坑 5:误把敏感文件打进镜像
常见来源
.env- 私钥
- 云厂商凭证
- npm token
- git 凭证
排查方式
docker run --rm -it your-image sh
find /app -maxdepth 2 -type f
解决
- 用
.dockerignore - 用构建参数或 secret,不要直接
COPY - CI 中不要把敏感信息写入镜像层
安全/性能最佳实践
这一部分可以当成团队规范来用。
1. 运行时镜像尽量最小化
原则不是“最小”,而是“只保留必要内容”。
建议保留:
- 应用二进制或编译产物
- 运行时依赖
- 必要配置与静态资源
建议不要保留:
- 编译工具链
- 源码
- 测试文件
- 文档
- 缓存目录
2. 使用非 root 用户运行
很多基础镜像默认是 root。生产环境中尽量避免。
USER node
如果是自定义用户:
RUN addgroup -S app && adduser -S app -G app
USER app
3. 固定基础镜像版本
不要只写:
FROM node:latest
推荐写明确版本:
FROM node:20-alpine
更严格一点可以固定 digest,不过这要结合你的供应链管理策略。
4. 优先使用 npm ci 而不是 npm install
原因:
- 更适合 CI
- 依赖更可重复
- 基于 lock 文件安装,结果更稳定
5. 充分利用缓存,但别滥用缓存
缓存适合:
- 包管理器下载缓存
- 依赖层缓存
缓存不适合:
- 把随时变化的大目录提前复制
- 把日志、构建产物带进上下文
6. 扫描镜像漏洞
至少在发布前做一次扫描。常见工具思路包括:
- Docker Scout
- Trivy
- Grype
示例:
trivy image demo-app:basic
如果高危漏洞来自基础镜像,优先考虑:
- 升级基础镜像版本
- 更换更干净的发行版
- 减少不必要包安装
7. 在 CI/CD 中拆分“构建镜像”和“发布镜像”职责
一个更稳妥的流程通常是:
- 代码提交
- 执行测试
- 构建多阶段镜像
- 漏洞扫描
- 打标签
- 推送仓库
- 部署到环境
发布流程示意图
flowchart TD
A[代码提交] --> B[单元测试]
B --> C[Docker 多阶段构建]
C --> D[镜像漏洞扫描]
D --> E{是否通过}
E -- 否 --> F[阻断发布]
E -- 是 --> G[推送镜像仓库]
G --> H[部署到测试/生产环境]
一个更完整的生产示例
下面给一份相对均衡的 Dockerfile,适合大多数 Node.js 服务参考。
# syntax=docker/dockerfile:1.6
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:20-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
&& npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
这版的特点
deps阶段专门处理依赖builder复用依赖进行构建runner单独安装生产依赖- 阶段职责清晰,后期维护更方便
如果你追求极致构建速度,也可以在某些场景下直接从 deps 复制依赖到 builder 和 runner,但要注意 dev/prod 依赖混淆问题。
如何判断“瘦身是否过度”
这是一个很现实的问题。不是镜像越小越好。
可以接受的“不过度”标准
- 构建过程清晰,团队能看懂
- 本地和 CI 可稳定复现
- 运行环境不缺依赖
- 安全扫描结果可控
- 镜像大小明显优于旧方案
可能过度优化的信号
- 为了小几十 MB,引入很复杂的脚本
- 使用过于冷门的基础镜像,排障困难
- 牺牲可读性,导致团队没人敢改 Dockerfile
- Alpine 兼容性问题频发
我比较建议的顺序是:
- 先做多阶段构建
- 再补
.dockerignore - 再优化 Dockerfile 缓存层
- 再做非 root、安全扫描
- 最后才考虑极限瘦身
总结
如果你只记住一句话,那就是:
把“构建环境”和“运行环境”分开,是 Docker 工程化的第一步。
多阶段构建带来的价值不只是镜像变小,它实际上同时解决了四类问题:
- 体积:最终镜像更轻
- 速度:构建缓存更稳定,CI 更快
- 安全:减少工具链和无关文件,缩小攻击面
- 可维护性:构建职责清晰,排障更容易
最后给你几个可以直接执行的建议:
- 先检查现有 Dockerfile 里是否有
COPY . .过早出现 - 立即补上
.dockerignore - 将构建与运行拆成至少两个阶段:
builder和runner - 生产镜像只装运行依赖,尽量使用非 root 用户
- 在 CI 中增加镜像漏洞扫描和基础镜像升级策略
如果你的项目已经上了 Docker,但镜像还很大、构建还很慢、发布还不够稳,那么多阶段构建通常是最值得优先改造的一步。它不是技巧,而是生产环境里的基本功。