Docker 多阶段构建与镜像瘦身实战:从构建提速到生产环境安全交付
很多团队一开始用 Docker,都是“能跑就行”:一个 Dockerfile 里从装依赖、编译、打包,到最终启动,全都塞进同一个镜像。短期看确实省事,但项目一旦进入 CI/CD、发布频繁、环境复杂、审计严格的阶段,问题会集中爆发:
- 镜像体积大,拉取慢,发布耗时长
- 构建缓存利用率差,稍微改点代码就全量重建
- 编译工具、包管理器、临时文件都进了生产镜像
- 漏洞扫描一堆告警,安全团队看了直皱眉
- 本地能跑,线上却因为基础镜像、依赖版本差异出问题
这篇文章我会带你从**“为什么要多阶段构建”讲到“怎么落地瘦身、提速和安全交付”**,并给出一套可运行示例。重点不是背语法,而是让你形成一套在项目里能直接用的思路。
背景与问题
先看一个很常见的“初级版” Dockerfile,以 Node.js 服务为例:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
它的问题几乎一眼就能数出来:
-
构建上下文太粗暴
COPY . .把所有文件都复制进去,包括.git、本地缓存、测试文件、文档等。
-
依赖安装不可控
npm install会根据环境变化安装依赖,结果不够稳定。- 开发依赖也会被带进生产镜像。
-
构建工具进入生产环境
npm、编译依赖、甚至gcc/python/make这类东西,可能全留在最终镜像里。
-
缓存命中差
- 代码一变,
COPY . .导致后续层全部失效,构建时间明显变长。
- 代码一变,
-
安全面扩大
- 镜像里包含越多组件,漏洞面就越大。
- 默认 root 用户运行,生产环境风险更高。
如果你在 CI 里每次构建都要 5~10 分钟,镜像几百 MB 甚至上 GB,发布时每台机器都在慢吞吞拉镜像,这就是该下决心治理的时候了。
前置知识与环境准备
建议你具备以下基础:
- 会写基础 Dockerfile
- 了解镜像分层与缓存
- 会基本的 Node.js 项目构建方式
本文示例环境:
- Docker 20.10+
- 推荐启用 BuildKit
- 示例项目:一个简单 Node.js Web 服务
先确认本机支持 BuildKit:
export DOCKER_BUILDKIT=1
docker buildx version
如果能正常输出版本,说明可以继续。
核心原理
多阶段构建的核心思想其实很朴素:
把“构建环境”和“运行环境”拆开,只把最终运行真正需要的产物复制到生产镜像中。
比如一个前端或 Node 服务构建过程通常需要:
- 安装依赖
- 编译打包
- 生成
dist/ - 启动运行
这里真正部署时需要的,往往只有:
- 编译后的产物
- 生产依赖
- 必要配置
- 一个最小可运行基础镜像
而像下面这些内容,通常只在构建阶段需要:
typescriptwebpack/vitegcc/make/python- 源码里的测试、脚本、文档
- 包管理器缓存
多阶段构建流程图
flowchart LR
A[源码与依赖清单] --> B[builder 阶段<br/>安装依赖/编译]
B --> C[生成构建产物 dist]
C --> D[runtime 阶段<br/>仅复制 dist 和生产依赖]
D --> E[最终生产镜像]
镜像分层与缓存的关键点
Docker 构建是按层进行的,前面的层不变,后面的层就有机会复用缓存。所以 Dockerfile 的顺序非常重要。
一个优化后的思路应该是:
- 先复制依赖描述文件,如
package.json、package-lock.json - 安装依赖
- 再复制业务代码
- 执行构建
这样业务代码变了,依赖层仍然能复用缓存。
构建与运行分离的安全收益
sequenceDiagram
participant Dev as 开发者
participant CI as CI构建机
participant Builder as builder镜像
participant Runtime as runtime镜像
participant Prod as 生产环境
Dev->>CI: 提交代码
CI->>Builder: 安装依赖并构建
Builder->>Runtime: 仅复制产物与运行依赖
Runtime->>Prod: 推送并部署
Note over Prod: 无编译工具、无源码、攻击面更小
这一步非常关键:生产镜像不应该承担“构建职责”。它只负责稳定地运行。
实战代码(可运行)
下面我们从一个简单 Node.js 服务开始,做一版“可直接跑”的多阶段构建。
示例项目结构
demo-app/
├── src/
│ └── index.js
├── package.json
├── package-lock.json
├── .dockerignore
└── Dockerfile
示例代码
src/index.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 is running on port ${port}`);
});
package.json
{
"name": "demo-app",
"version": "1.0.0",
"description": "Docker multi-stage demo",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"express": "^4.19.2"
}
}
先在本地生成锁文件:
npm install
第一步:先看一个“能用但不优”的版本
单阶段 Dockerfile
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
构建镜像:
docker build -t demo-app:single .
运行容器:
docker run -d -p 3000:3000 --name demo-single demo-app:single
测试:
curl http://localhost:3000
这个版本已经比最粗暴的 COPY . . 再安装依赖好一点了,但仍然有两个问题:
- 生产镜像里仍然带有 npm、完整 node 基础环境以及构建中间内容
- 如果项目涉及编译打包,这种方式仍会把很多无关内容留在最终镜像中
第二步:改造成多阶段构建
多阶段 Dockerfile
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:18-alpine AS runtime
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package*.json ./
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "src/index.js"]
这个版本适合没有前端打包、没有 TypeScript 编译的简单服务。它的核心变化是:
- 依赖安装放到
deps阶段 - 运行镜像只复制必要文件
- 使用
npm ci --omit=dev,更适合 CI 和生产环境 - 使用
node非 root 用户运行
构建并运行:
docker build -t demo-app:multi .
docker run -d -p 3000:3000 --name demo-multi demo-app:multi
curl http://localhost:3000
第三步:加入“构建阶段”,适合 TypeScript/前端打包场景
在真实项目里,往往会有 build 动作,比如 TypeScript 编译、前端打包、NestJS 构建等。这时通常要拆成三个阶段:
deps:安装完整依赖builder:执行构建runtime:只保留运行所需内容
典型三阶段 Dockerfile
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:18-alpine AS runtime
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]
这个写法很常见,也比较稳妥。它的思路是:
- 构建阶段需要完整依赖,包括 devDependencies
- 运行阶段重新安装生产依赖,避免把开发依赖带进去
- 最终只复制
dist/
三阶段构建关系图
flowchart TD
A[deps<br/>npm ci] --> B[builder<br/>COPY source + npm run build]
B --> C[runtime<br/>npm ci --omit=dev]
B --> D[dist 产物]
D --> C
C --> E[生产镜像]
第四步:加上 .dockerignore,这是镜像瘦身的低成本高收益动作
很多人做了多阶段构建,但忘了 .dockerignore,结果仍然把一堆没必要的文件传给 Docker daemon。
.dockerignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
coverage
dist
.env
*.local
这里有两个实际收益:
- 减少构建上下文大小
- 避免敏感文件误入镜像
我自己踩过的坑是:某次同事把 .env.production 放在项目目录里,本地构建时直接被 COPY . . 带进镜像,最后漏洞扫描才发现。这个问题其实完全可以靠 .dockerignore 预防。
第五步:使用 BuildKit 缓存进一步提速
多阶段构建解决的是“干净交付”,但要把构建速度再往前推,可以利用 BuildKit 的缓存挂载。
带缓存的 Dockerfile
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:18-alpine AS runtime
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]
构建命令:
DOCKER_BUILDKIT=1 docker build -t demo-app:cache .
这类缓存对 CI 里的重复构建很有帮助,尤其依赖不经常变化时,提速会比较明显。
逐步验证清单
写完 Dockerfile,不建议“构建成功就算完”。我更推荐按下面清单验一遍。
1. 看镜像体积
docker images | grep demo-app
2. 看镜像层历史
docker history demo-app:multi
观察是否还有明显的大层,比如:
- 安装编译工具
- 大量源码复制
- 不必要缓存
3. 进入容器检查内容
docker run --rm -it demo-app:multi sh
检查是否有这些问题:
whoami
ls -lah
du -sh /app
你应该关注:
- 当前是否为非 root 用户
- 是否只保留最小运行文件
- 有没有测试目录、源码、缓存文件残留
4. 验证服务是否正常
docker run --rm -p 3000:3000 demo-app:multi
curl http://localhost:3000
5. 扫描漏洞
如果环境允许,可以用 Trivy:
trivy image demo-app:multi
常见坑与排查
多阶段构建本身不复杂,但在真实项目里,经常会遇到一些很具体的问题。
坑 1:COPY --from=builder 路径写错
现象:
- 构建成功或半成功,但运行时提示找不到入口文件
- 容器启动后报
Cannot find module或No such file or directory
比如你写了:
COPY --from=builder /app/build ./dist
但实际构建产物在 /app/dist。
排查方式:
docker build --target builder -t demo-builder .
docker run --rm -it demo-builder sh
ls -lah /app
ls -lah /app/dist
这招非常好用:先单独进入中间阶段看实际文件布局。
坑 2:Alpine 很小,但不是所有项目都适合
alpine 的优势是小,但它使用 musl libc,某些原生模块可能跟 glibc 生态不完全一致。
现象:
- 本地运行没问题,容器里某些依赖崩溃
- 涉及原生扩展的模块安装失败
排查思路:
- 看依赖是否包含 native module
- 临时切回
node:18-slim试验 - 对比构建日志与运行日志
如果项目依赖比较“重”或者包含原生编译模块,slim 往往是更稳妥的折中。
坑 3:npm install 和 npm ci 混用导致结果不一致
生产环境里我更建议优先使用:
npm ci
原因:
- 基于锁文件,安装结果更可预测
- 更适合 CI/CD
- 对版本漂移更敏感,更容易暴露问题
如果你的 package-lock.json 不存在或与 package.json 不一致,npm ci 会直接失败。虽然一开始觉得“麻烦”,但这恰恰是在帮你尽早发现不一致。
坑 4:容器里能构建,运行时权限报错
常见原因:
- 最终切换到
USER node后,复制进去的文件属主不对 - 应用运行时需要写目录,但目录没有权限
可以在复制时显式设置属主:
COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node package*.json ./
如果应用需要写日志或临时文件,也要提前创建目录并授权。
坑 5:构建缓存失效,速度忽快忽慢
典型错误写法:
COPY . .
RUN npm ci
这样任何代码变动都会让依赖安装层失效。
更好的顺序:
COPY package*.json ./
RUN npm ci
COPY . .
这点看似细节,但在 CI 里影响非常大。
安全/性能最佳实践
这一节我想把“镜像瘦身”和“生产交付”真正串起来。瘦身不是目的,稳定、安全、可审计地上线才是。
1. 优先选择更小但稳定的基础镜像
常见选择:
alpine:小,适合纯解释型、依赖简单的服务slim:比完整镜像小,同时兼容性更稳- distroless:更极致,但调试成本更高
一个简单判断:
- 追求极致体积且依赖简单:
alpine - 追求稳定兼容与较小体积:
slim - 对安全和最小运行面要求极高:distroless
边界条件是:别为了小而小。如果 Alpine 导致你排查 native 依赖花了一整天,那不一定划算。
2. 生产镜像中不要保留构建工具
最终镜像应尽量避免出现:
gitcurlwgetgccmake- 包管理器缓存
- 测试文件与源码
如果确实需要调试能力,也更建议:
- 用临时 debug 容器
- 或者保留独立 debug 镜像
- 而不是污染生产镜像
3. 使用非 root 用户运行
最少要做到:
USER node
更严格一点的场景,可以自建用户:
RUN addgroup -S app && adduser -S app -G app
USER app
这样即使应用被利用,攻击者能获取的权限也相对受限。
4. 控制镜像内容的可预测性
建议:
- 固定基础镜像大版本,最好固定 digest
- 使用锁文件
- 统一 CI 构建环境
- 避免运行时动态安装依赖
例如,不要在容器启动命令里写:
npm install && npm start
这会把“可重复交付”变成“现场碰运气”。
5. 做好健康检查与最小暴露
可以加上健康检查:
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget -qO- http://127.0.0.1:3000/ || exit 1
如果镜像里没有 wget/curl,也可以通过编排层做探针检查。这里要注意:健康检查本身不要引入额外攻击面和大量依赖。
6. 在 CI/CD 中结合镜像扫描与签名
完整的生产交付链路建议包含:
- 镜像构建
- 漏洞扫描
- 合规检查
- 签名或制品追踪
- 部署准入控制
生产交付建议流程
flowchart LR
A[代码提交] --> B[CI 构建多阶段镜像]
B --> C[单元测试/集成测试]
C --> D[漏洞扫描]
D --> E[镜像签名/制品归档]
E --> F[部署到生产环境]
这一步的意义在于:你交付的不只是一个“能跑的容器”,而是一个来源可追踪、内容可验证、风险可评估的制品。
7. 用 docker history 和实际运行内容反推优化点
我通常会从两个角度看镜像是否还可继续瘦身:
docker history:看哪些层大- 进容器
find /app:看哪些文件不该出现
经验上,最容易继续优化的地方往往有:
- 没加
.dockerignore - 开发依赖混入生产
- 构建缓存和安装缓存未清理
- 产物复制路径过宽,整个项目都进了最终镜像
一份更接近生产的 Dockerfile 模板
下面给一份中规中矩、适合大多数 Node 服务的模板:
# syntax=docker/dockerfile:1.4
FROM node:18-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:18-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:18-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
&& npm cache clean --force
COPY --from=builder /app/dist ./dist
RUN useradd -r -s /usr/sbin/nologin appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
如果你是前端静态站点,运行阶段甚至可以替换成 nginx:alpine,只复制打包后的静态文件,这会更小。
什么时候不必过度设计多阶段构建
虽然我一直推荐多阶段构建,但也不是所有项目都值得上很复杂的模板。
可以适度简化的情况:
- 很小的内部工具脚本
- 纯运行型服务,没有编译过程
- 只在内网临时环境使用
- 生命周期很短的 PoC
但即便如此,至少也建议做到:
.dockerignore- 非 root 用户
- 依赖和代码分层复制
- 尽量不用
latest
也就是说,不一定非要“完美”,但最好别停留在“把整个目录扔进去能跑就行”的状态。
总结
如果把这篇文章浓缩成一句话,那就是:
多阶段构建不是为了炫技,而是为了把“构建环境”和“生产环境”彻底分开,让镜像更小、构建更快、交付更安全。
你可以按下面的优先级落地:
- 先补
.dockerignore - 把
COPY package*.json和依赖安装前置,优化缓存 - 引入多阶段构建,分离 builder 与 runtime
- 运行阶段只保留产物和生产依赖
- 使用非 root 用户
- 在 CI 中结合 BuildKit 缓存、漏洞扫描和制品治理
如果你现在的镜像还很重,别一上来就追求“极致最小化”。我更建议你先达到这三个目标:
- 体积明显下降
- 构建时间稳定可控
- 生产镜像不再携带构建工具和无关文件
做到这一步,Docker 镜像就已经从“能用”走向“可交付”了。