Docker 多阶段构建与镜像瘦身实战:从构建加速到生产环境安全发布
很多团队一开始写 Dockerfile,目标都很朴素:先跑起来再说。结果项目上线几个月后,镜像越来越大、构建越来越慢、漏洞扫描越来越多,发布链路也开始变得不稳定。
我自己就踩过一个很典型的坑:一个 Node.js 服务,业务代码其实不到 50 MB,但最终镜像做到了 1.2 GB。CI 每次构建拉基础镜像都慢,发布时推送镜像更慢,安全扫描里一堆其实根本不会在生产运行时用到的构建工具漏洞。后来回过头看,本质问题只有一句话:
把“构建环境”和“运行环境”混在一起了。
这篇文章不讲空泛概念,直接带你从问题出发,理解多阶段构建为什么能解决镜像臃肿、构建缓慢和生产风险,并通过一个可运行的示例,把“开发可构建、生产可发布”的流程串起来。
背景与问题
在没有多阶段构建之前,很多 Dockerfile 大概长这样:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
看起来没毛病,但实际上隐藏了几个常见问题:
-
镜像过大
- 把源码、测试文件、构建缓存、开发依赖都带进去了。
npm install默认会装开发依赖,生产根本用不到。
-
构建慢
COPY . .太早,任何代码变动都会让依赖层缓存失效。- 每次 CI 都从头装依赖、重新构建。
-
安全面扩大
- 运行时镜像里包含编译器、包管理器、调试工具。
- 漏洞扫描结果噪音大,真正的高风险项不容易看清。
-
发布不稳定
- 本地能跑,不代表容器内最终运行环境一致。
- 构建产物和运行依赖之间边界不清晰。
所以,多阶段构建并不只是“让镜像变小”,它更像是一个工程化分层手段:把构建、测试、打包、运行拆开。
前置知识与环境准备
建议你本地先准备:
- Docker 24+
- 启用 BuildKit(推荐)
- 一台可以联网拉镜像的机器
先确认版本:
docker version
docker buildx version
启用 BuildKit 的一个简单方式:
export DOCKER_BUILDKIT=1
如果你用 Docker Desktop,通常默认已经开启。
核心原理
什么是多阶段构建
多阶段构建的核心,就是在一个 Dockerfile 里写多个 FROM,每个阶段只负责一件事:
- 一个阶段装依赖
- 一个阶段编译代码
- 一个阶段只保留最终运行所需文件
最后通过 COPY --from=阶段名,只把必要产物复制到最终镜像。
一个直观流程图
flowchart LR
A[源码与配置] --> B[依赖安装阶段]
B --> C[构建阶段]
C --> D[测试阶段]
C --> E[生产运行阶段]
B -.缓存复用.-> B
E --> F[发布到镜像仓库]
为什么它能同时解决“体积、速度、安全”
可以从三个维度理解:
1. 体积变小
最终镜像只保留:
- 编译后的产物
- 运行时依赖
- 最小化基础系统
不再带上:
- 源码
- 编译工具链
- 测试工具
- 构建缓存
2. 构建更快
通过分层设计,把最不常变化的步骤放前面,让缓存尽可能命中,比如:
- 先复制
package.json/package-lock.json - 再安装依赖
- 最后复制业务代码
这样改一行业务代码,不需要重装依赖。
3. 更安全
最终生产镜像里没有:
- gcc、make、python 这类编译工具
- git、curl 等不必要工具
- 测试与调试脚本
攻击面更小,漏洞数也会明显下降。
一个分阶段发布模型
sequenceDiagram
participant Dev as 开发者
participant CI as CI/CD
participant Builder as 构建阶段镜像
participant Runtime as 运行阶段镜像
participant Registry as 镜像仓库
participant Prod as 生产环境
Dev->>CI: 提交代码
CI->>Builder: 安装依赖并构建
Builder->>Builder: 执行测试/产出 dist
CI->>Runtime: 复制 dist 与生产依赖
Runtime->>Registry: 推送最终镜像
Registry->>Prod: 拉取并部署
实战代码(可运行)
下面我们用一个 Node.js + Express 的最小示例,演示:
- 普通构建方式的问题
- 多阶段构建的改造方法
- 如何验证镜像体积和运行结果
示例目录结构
demo-app/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
├── src/
│ └── server.js
第一步:准备示例应用
package.json
{
"name": "demo-app",
"version": "1.0.0",
"description": "docker multi-stage demo",
"main": "dist/server.js",
"scripts": {
"build": "mkdir -p dist && cp src/server.js dist/server.js",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"nodemon": "^3.1.4"
}
}
src/server.js
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({
message: 'hello from multi-stage docker build',
time: new Date().toISOString()
});
});
app.listen(port, () => {
console.log(`server listening on ${port}`);
});
生成锁文件
如果你本地没有 package-lock.json,执行:
npm install
第二步:先看一个“不够好”的 Dockerfile
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "start"]
这个版本能跑,但它的问题非常典型:
- 开发依赖也被装进镜像
- 全量源码、缓存都被复制进去
- 代码一改,
npm install缓存就失效 - 最终镜像包含完整 Node 构建环境
第三步:改造为多阶段构建
下面是一个更实用的版本。
Dockerfile
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
FROM deps AS build
COPY src ./src
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]
这个版本做了几件关键的事:
deps阶段:只安装依赖,利于缓存build阶段:只复制源码并生成distproduction阶段:重新安装生产依赖,不带 devDependencies- 最终以
node非 root 用户运行
第四步:补上 .dockerignore
这个文件非常重要,很多人做了多阶段构建,却忘了它,结果上下文还是巨大。
.dockerignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
dist
coverage
*.md
它的作用是:减少构建上下文。
如果不加,Docker 在 build 时会先把一堆无关文件打包传给守护进程,网络构建时尤其慢。
第五步:构建镜像
docker build -t demo-app:multi .
查看镜像大小:
docker images | grep demo-app
如果你也保留了“单阶段版 Dockerfile”做对比,通常会看到多阶段版体积明显更小。
第六步:运行并验证
启动容器:
docker run --rm -p 3000:3000 demo-app:multi
另开一个终端访问:
curl http://localhost:3000/
预期返回:
{"message":"hello from multi-stage docker build","time":"2025-08-29T09:39:25.000Z"}
逐步验证清单
如果你想确认“瘦身”和“安全发布”是否真的生效,可以按这个顺序检查:
1. 检查最终镜像里是否没有源码目录
docker run --rm demo-app:multi sh -c "ls -R /app"
你应该看到主要只有:
distpackage.jsonpackage-lock.jsonnode_modules
而不是完整的 src、测试目录、Git 元数据。
2. 检查运行用户不是 root
docker run --rm demo-app:multi id
输出中不应是 uid=0(root)。
3. 检查开发依赖是否未被安装
docker run --rm demo-app:multi sh -c "ls node_modules | grep nodemon || true"
正常应无输出。
4. 查看构建历史
docker history demo-app:multi
可以观察哪些层体积大,判断后续还能不能继续优化。
构建缓存优化:让 CI 不再“每次从零开始”
多阶段构建解决了结构问题,但想要构建加速,还要进一步利用 BuildKit 缓存。
使用缓存挂载优化 npm 安装
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM deps AS build
COPY src ./src
RUN npm run build
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
这样做的好处是:
- 即使镜像层失效,npm 下载缓存仍可复用
- 在 CI/CD 中重复构建速度会更稳定
缓存命中策略图
flowchart TD
A[复制 package.json 和 lock 文件] --> B[npm ci]
B --> C[复制源码]
C --> D[npm run build]
D --> E[生成生产镜像]
F[仅修改业务代码] -.-> C
G[修改依赖声明] -.-> A
G --> B
这张图背后的原则很简单:
- 依赖声明变了,才重新装依赖
- 业务代码变了,只重做构建
- 这就是 Docker 层缓存最常见、也最有效的优化手法
常见坑与排查
这一部分我尽量讲“真实会遇到的坑”,不是教材式罗列。
1. COPY --from=build 路径写错
现象:
COPY --from=build /app/build ./dist
但你的实际构建产物在 /app/dist,构建时报错:
failed to compute cache key: "/app/build" not found
排查方法:
docker build --target build -t demo-app:build .
docker run --rm -it demo-app:build sh
ls -R /app
先进入中间阶段镜像看文件结构,是最快的。
2. npm ci 失败,提示 lock 文件不一致
现象:
npm ERR! `npm ci` can only install packages when your package.json and package-lock.json are in sync
原因:
- 你改了
package.json - 却没有更新
package-lock.json
解决:
npm install
git add package-lock.json
经验上,生产构建里优先用 npm ci 而不是 npm install,因为前者更可重复。
3. Alpine 镜像导致某些原生模块异常
现象:
某些依赖含原生编译模块时,在 node:alpine 下可能因为 musl libc 与 glibc 差异出现兼容问题。
解决思路:
- 优先尝试
node:20-slim - 如果你依赖
sharp、grpc、bcrypt这类模块,更要提前验证 - 不要盲目认为 Alpine 一定最好,小不等于适合
一个更稳妥的生产阶段示例:
FROM node:20-slim AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
4. 切换非 root 用户后权限报错
现象:
EACCES: permission denied
原因:
复制进去的文件属主还是 root,而运行用户是 node。
解决方式之一:
COPY --chown=node:node --from=build /app/dist ./dist
COPY --chown=node:node package.json package-lock.json ./
或者在切换用户前统一调整权限:
RUN chown -R node:node /app
USER node
5. .dockerignore 配置不当导致构建失败
现象:
你把 package-lock.json 也忽略掉了,结果 COPY package.json package-lock.json ./ 直接报错。
排查建议:
先看 .dockerignore,很多构建问题根本不是 Dockerfile 本身,而是上下文文件没传进去。
6. 误把测试工具带进生产镜像
有些人会这么写:
FROM build AS production
CMD ["node", "dist/server.js"]
这其实只是“换了个名字”,并没有真正缩减运行环境。因为 build 阶段里往往还带着:
- devDependencies
- 源码
- 构建工具
- 测试脚本
正确思路是:最终阶段要重新定义运行时边界。
安全/性能最佳实践
这里我把实战里最有价值的建议整理成一组可执行清单。
1. 基础镜像尽量明确版本
不建议:
FROM node:latest
建议:
FROM node:20-alpine
或者:
FROM node:20-slim
好处:
- 构建结果更稳定
- 避免上游镜像变化导致不可预期问题
2. 使用非 root 用户运行
这是生产环境最基础也最有效的一条。
USER node
如果镜像没有内置普通用户,就自己创建:
RUN addgroup -S app && adduser -S app -G app
USER app
3. 只复制必要文件
不要一上来就:
COPY . .
更推荐:
COPY package.json package-lock.json ./
COPY src ./src
这件事既影响缓存,也影响安全边界。
4. 在生产镜像中移除开发依赖
Node 场景下:
RUN npm ci --omit=dev
Python 场景可以只安装 requirements.txt 中的生产依赖;Go 场景则通常直接复制二进制。
5. 善用最小运行时镜像,但别盲目极限瘦身
常见选择大概是:
alpine:体积小,但兼容性要验证slim:更稳妥,体积适中distroless:更安全、更小,但调试难度高
如果你是中级读者、团队还在完善工程化,我的建议是:
- 先从
slim或alpine开始 - 等流程稳定,再考虑 distroless
6. 将测试放在中间阶段,而不是最终阶段
你可以加入 test 阶段:
FROM deps AS test
COPY src ./src
RUN npm run build
RUN npm test
然后 CI 只在测试通过后才构建生产镜像。这能把“质量门禁”和“生产产物”分离开。
7. 为生产镜像增加健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://localhost:3000/').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
这不是必须,但对容器编排环境很有帮助。
8. 构建完成后做镜像扫描
常见做法:
docker scout quickview demo-app:multi
或者使用:
- Trivy
- Grype
- Snyk
重点不是“零漏洞”神话,而是识别:
- 高危且可利用的漏洞
- 实际存在于运行层的漏洞
- 是否能通过升级基础镜像快速消除
9. 为镜像打上清晰标签
比如:
docker build -t registry.example.com/demo-app:1.0.0 -t registry.example.com/demo-app:latest .
更进一步可以加 Git 提交号:
docker build -t registry.example.com/demo-app:1.0.0 \
-t registry.example.com/demo-app:git-abc1234 .
这样回滚更容易,也更方便排查问题。
一个更贴近生产的 Dockerfile 示例
如果你想直接拿去做模板,下面这个版本会更像生产实践:
# syntax=docker/dockerfile:1.7
FROM node:20-slim AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM deps AS build
COPY src ./src
RUN npm run build
FROM deps AS test
COPY src ./src
RUN npm run build
RUN node dist/server.js & sleep 2 && kill $!
FROM node:20-slim AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
&& npm cache clean --force
COPY --from=build /app/dist ./dist
RUN chown -R node:node /app
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
构建生产镜像:
docker build --target production -t demo-app:prod .
如果只想调试构建阶段:
docker build --target build -t demo-app:build .
这就是多阶段构建另一个很实用的点:你可以按阶段调试,而不是一次性黑盒执行到底。
什么时候不必过度优化
虽然我很推荐多阶段构建,但也要说边界条件。
如果你的项目:
- 只是本地临时开发工具
- 不会进入生产
- 镜像只在个人机器使用
- 构建频率非常低
那你未必需要把 Dockerfile 设计得很复杂。
但只要满足下面任一条件,就值得认真做:
- 要进 CI/CD
- 要部署到线上
- 需要漏洞扫描
- 团队多人协作
- 构建速度已经影响研发体验
换句话说,多阶段构建不是“高级技巧”,而是生产交付的基础能力。
总结
把这篇文章压缩成几条最重要的落地建议,就是:
-
把构建和运行彻底分开
- 用多个
FROM - 最终镜像只保留运行时必要内容
- 用多个
-
优化缓存顺序
- 先复制依赖描述文件
- 后复制业务代码
- 尽量让依赖层稳定复用
-
减少上下文与依赖
- 写好
.dockerignore - 生产环境只装生产依赖
- 写好
-
默认按安全标准构建
- 固定基础镜像版本
- 使用非 root 用户
- 做镜像扫描
-
不要迷信最小镜像,要结合兼容性
alpine更小slim往往更稳- 生产中稳定性优先于极限瘦身
如果你现在的 Dockerfile 还是“一个阶段把所有事做完”,最值得马上动手的一步不是追求极限,而是先把它拆成:
depsbuildproduction
只要完成这一步,你通常就已经同时收获了: 更小的镜像、更快的构建、更清晰的发布边界,以及更安全的生产环境。