Docker 多阶段构建与镜像瘦身实战:从构建优化到生产环境安全发布
很多团队刚开始用 Docker 时,最常见的状态是:能跑就行。
于是 Dockerfile 里把编译工具、源码、测试依赖、调试命令全塞进去,镜像从几百 MB 一路膨胀到 1GB 以上,构建慢、拉取慢、发布慢,安全风险还高。
我自己第一次接手这类项目时,就见过一个 JavaScript 服务镜像接近 1.2GB,CI 每次构建都像“等电梯”。后来逐步引入多阶段构建、基础镜像收敛、非 root 运行、最小化拷贝之后,镜像体积和发布时间都明显下降,排查问题也更清晰了。
这篇文章就从实战角度带你做一遍,重点解决三个问题:
- 为什么镜像会臃肿?
- 多阶段构建到底在优化什么?
- 怎样把“能运行的镜像”变成“适合生产环境发布的镜像”?
背景与问题
在真实项目里,Docker 镜像变胖通常不是单一原因,而是几个问题叠加:
- 使用了过大的基础镜像,比如直接
ubuntu、node:full、openjdk全量版 - 构建工具和运行环境混在一起
- 把整个项目目录都
COPY进去,连.git、测试文件、文档都没放过 - 安装依赖时没有利用缓存,导致每次构建都全量重来
- 最终镜像里保留了编译器、包管理器缓存、临时文件
- 容器默认 root 运行,发布到生产后权限面过大
这些问题的直接后果包括:
- CI/CD 构建时间长
- 镜像仓库占用高
- 节点拉镜像慢,扩容慢
- 漏洞扫描结果一大堆,修都不好修
- 生产环境被入侵时,攻击面更大
先看一个典型的“臃肿版”流程:
flowchart TD
A[源码目录] --> B[单阶段 Dockerfile]
B --> C[安装构建依赖]
C --> D[复制全部源码]
D --> E[编译打包]
E --> F[保留 node_modules源码缓存工具链]
F --> G[生成大镜像]
G --> H[推送慢 拉取慢 风险高]
这就是很多项目的起点:构建方便,但运行不经济。
前置知识与环境准备
本文示例使用一个简单的 Node.js Web 服务演示,因为它既有“构建阶段”,也有“运行阶段”,比较适合说明多阶段构建。
环境要求
- Docker 20+
- 建议启用 BuildKit
- Linux / macOS / WSL2 均可
启用 BuildKit:
export DOCKER_BUILDKIT=1
示例目录结构如下:
demo-app/
├── app.js
├── package.json
├── package-lock.json
├── .dockerignore
└── Dockerfile
示例代码
app.js
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({
message: 'hello docker multi-stage build',
time: new Date().toISOString()
});
});
app.listen(port, () => {
console.log(`server started on ${port}`);
});
package.json
{
"name": "demo-app",
"version": "1.0.0",
"description": "docker multi-stage demo",
"main": "app.js",
"scripts": {
"start": "node app.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
核心原理
什么是多阶段构建
多阶段构建的核心思想很朴素:
构建阶段需要的东西,不一定要进入最终运行镜像。
你可以在同一个 Dockerfile 里定义多个阶段:
- 第一阶段:安装依赖、编译、打包
- 第二阶段:只复制构建产物和最小运行依赖
这样最终镜像里只保留“运行真正需要的内容”。
为什么它能瘦身
镜像是按层叠加的。每一条 RUN、COPY、ADD 都可能产生新层。
如果你在早期层里装了 gcc、make、git、测试工具,即使后面删除,也未必真的让镜像变小,因为这些内容已经进入历史层了。
多阶段构建直接从结构上避免了这个问题:
构建工具所在阶段不会进入最终镜像。
关键优化点
1. 分离构建环境与运行环境
例如:
- 构建阶段用
node:20-bookworm - 运行阶段用
node:20-alpine或更轻量的运行镜像
2. 优化缓存命中
先复制依赖描述文件,再安装依赖,最后复制源码:
COPY package*.json ./
RUN npm ci
COPY . .
这样当源码变化但依赖不变时,npm ci 这一层能复用缓存。
3. 最小化拷贝范围
不要直接把整个目录无脑塞进去,配合 .dockerignore 使用,效果非常明显。
4. 以非 root 用户运行
镜像瘦身和安全发布其实是一体两面:
镜像越小,包含的工具越少,攻击面越小;
权限越收敛,生产风险越低。
实战代码(可运行)
下面我们先写一个“可用但不优”的版本,再重构成生产可用版本。
第一步:一个不推荐的单阶段 Dockerfile
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["npm", "start"]
这个版本的问题很典型:
- 基础镜像偏大
COPY . .太早,缓存利用差- 没有
.dockerignore npm install不够稳定,生产构建更推荐npm ci- 默认 root 运行
- 源码、测试、缓存全都可能进入镜像
构建并运行:
docker build -t demo-app:fat .
docker run --rm -p 3000:3000 demo-app:fat
查看镜像体积:
docker images | grep demo-app
第二步:编写 .dockerignore
先把没必要进入构建上下文的内容排除掉。
.dockerignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
coverage
dist
*.local
这一步经常被忽略,但很值。
因为 Docker 构建时会先把上下文打包发给 daemon,目录越大,构建越慢。
第三步:升级为多阶段构建
Dockerfile
# syntax=docker/dockerfile:1.6
FROM node:20-bookworm AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app /app
USER node
EXPOSE 3000
CMD ["node", "app.js"]
这个版本已经比单阶段好很多了,但还可以继续收紧。
第四步:进一步瘦身与安全收敛
对于这个简单服务,其实不需要在 builder 和 runtime 都保留完整应用目录。
可以按需复制。
更推荐的 Dockerfile
# syntax=docker/dockerfile:1.6
FROM node:20-bookworm AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules /app/node_modules
COPY app.js /app/app.js
COPY package.json /app/package.json
USER node
EXPOSE 3000
CMD ["node", "app.js"]
这个版本的特点:
- 依赖安装单独成阶段,职责清晰
- 最终镜像只复制
node_modules、app.js、package.json - 不把整个源码目录带入运行镜像
- 使用 Alpine 作为运行环境
- 使用非 root 用户
node
构建与运行验证
docker build -t demo-app:slim .
docker run --rm -p 3000:3000 demo-app:slim
访问:
curl http://localhost:3000/
预期输出:
{"message":"hello docker multi-stage build","time":"2025-01-01T00:00:00.000Z"}
查看镜像历史层
这个命令非常适合排查“为什么镜像还是大”:
docker history demo-app:slim
如果你看到某一层特别大,通常说明:
COPY . .拷了太多文件- 安装了不必要的软件包
- 某一步下载了大文件却没处理好
- 构建产物没和运行产物分离
逐步验证清单
实际做镜像优化时,我建议不要一步改完,而是按下面清单逐项验证:
- 是否使用了
.dockerignore - 是否区分了构建阶段与运行阶段
- 是否优先复制依赖文件以利用缓存
- 是否使用
npm ci而不是npm install - 是否去掉了开发依赖
- 是否只复制运行所需文件
- 是否使用非 root 用户
- 是否确认应用在容器内可正常启动
- 是否检查了镜像层历史
- 是否做了漏洞扫描
这个过程看起来啰嗦,但很实用。尤其在团队里推广时,有一份 checklist 比讲一堆概念更容易落地。
多阶段构建的执行流程
flowchart LR
A[deps阶段] --> B[复制 package.json package-lock.json]
B --> C[npm ci --omit=dev]
C --> D[runtime阶段]
D --> E[复制 node_modules]
D --> F[复制 app.js]
D --> G[复制 package.json]
E --> H[最终生产镜像]
F --> H
G --> H
这个图背后的重点是:
最终镜像不是继承完整构建过程,而是按需“摘取成果”。
生产环境安全发布流程
光把镜像做小还不够,生产发布要关注完整链路。
sequenceDiagram
participant Dev as 开发者
participant CI as CI流水线
participant Scan as 漏洞扫描
participant Registry as 镜像仓库
participant Prod as 生产环境
Dev->>CI: 提交代码与 Dockerfile
CI->>CI: 多阶段构建镜像
CI->>Scan: 扫描基础镜像与依赖漏洞
Scan-->>CI: 返回结果
alt 通过策略
CI->>Registry: 推送版本化镜像
Registry->>Prod: 拉取并发布
else 不通过
CI-->>Dev: 阻断发布并反馈问题
end
建议在流水线中至少加入:
- 镜像构建
- 单元测试或最小冒烟测试
- 漏洞扫描
- 镜像签名或来源校验
- 版本标签与回滚策略
常见坑与排查
下面这些问题,我基本都踩过,属于“看起来不复杂,但真会卡人”。
1. Alpine 镜像更小,但运行时报错
现象:
- 某些 Node 原生模块、Python 包、Java 依赖在 Alpine 上行为异常
- 提示缺少动态库,或者二进制不兼容
原因:
- Alpine 使用的是 musl libc,不是很多发行版常见的 glibc
排查方式:
docker run --rm -it demo-app:slim sh
进入容器后检查依赖和错误日志。
解决建议:
- 如果业务依赖原生库较多,不要盲目追求 Alpine
- 可改用
debian-slim一类镜像,在兼容性和体积之间折中
2. 明明删除了文件,镜像体积还是没降
原因:
- 删除动作发生在后续层,前面的层已经把文件保存下来了
错误示例:
RUN apt-get update && apt-get install -y build-essential
RUN apt-get remove -y build-essential
即使后面删了,镜像仍然可能很大。
解决建议:
- 把安装和清理放在同一层
- 更推荐直接用多阶段构建,把构建工具留在前一阶段
3. COPY . . 导致缓存失效
现象:
- 改一行代码,整个依赖安装都重新执行
原因:
- 依赖文件和业务代码一起复制,只要任意文件变化,缓存就失效
正确方式:
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
4. 容器非 root 后权限报错
现象:
- 程序启动时报权限不足
- 无法写日志、临时文件目录不可写
原因:
- 复制后的文件属主不匹配
- 应用仍尝试写入 root 才能访问的目录
解决方法:
COPY --chown=node:node app.js /app/app.js
COPY --chown=node:node package.json /app/package.json
如果目录需要写权限,也要确保属主和权限正确。
5. latest 标签导致回滚困难
现象:
- 发布后出问题,却说不清线上到底跑的是哪个镜像
- 回滚时只能“猜你上次推的是啥”
建议:
- 不要只推
latest - 使用明确版本号、Git SHA、构建时间等标签
例如:
docker build -t registry.example.com/demo-app:1.0.3 .
docker build -t registry.example.com/demo-app:git-abc1234 .
安全/性能最佳实践
这一部分我尽量给“能直接抄到项目里”的建议,而不是停留在口号。
1. 选择合适的基础镜像,而不是一味追求最小
经验上可以这么选:
- 兼容优先:
debian-slim - 极致瘦身:
alpine - 更强安全收敛:distroless 类镜像
- 构建阶段:带工具链的官方镜像
- 运行阶段:最小运行时镜像
边界条件是:
如果你依赖 native module、字体库、系统证书、调试工具,过度极简会提高排障成本。
2. 固定依赖与基础镜像版本
不要写这种:
FROM node:latest
更推荐:
FROM node:20.11-alpine
原因:
- 构建结果更可复现
- 避免某次上游更新导致行为变化
- 便于审计和回滚
3. 减少镜像中无关内容
重点清理这些内容:
- 包管理器缓存
- 测试数据
- 文档
.git- 本地构建输出
- 调试工具
- shell 历史记录或临时文件
4. 使用非 root 用户运行
最小要求:
USER node
如果是自定义用户:
RUN addgroup -S app && adduser -S app -G app
USER app
这是生产环境很基础但非常重要的一步。
5. 在 CI 里做镜像扫描
常见目标包括:
- 基础镜像系统包漏洞
- 应用依赖漏洞
- 敏感文件泄露
- 高危配置,如 root 用户、过多 capability
即使你暂时不做签名和准入策略,先把扫描接上,收益也很高。
6. 控制镜像层与指令顺序
把稳定、不常变化的步骤放前面,把易变化的源码放后面。
这样缓存利用率会更高,构建速度提升很明显。
一个典型排序是:
- 选择基础镜像
- 设置工作目录
- 复制依赖文件
- 安装依赖
- 复制业务代码
- 构建产物
- 进入运行阶段并复制最小必要内容
7. 尽量做到“镜像即制品”
也就是:
- 同一镜像可在测试、预发、生产复用
- 环境差异通过环境变量注入,而不是在镜像里写死
- 构建一次,到处运行
这样发布链路才稳定,回滚也简单。
一个更贴近生产的发布示例
下面给一个稍微完整一点的构建与发布命令示例。
构建
docker build -t registry.example.com/demo-app:1.0.0 \
-t registry.example.com/demo-app:git-$(git rev-parse --short HEAD) .
本地冒烟验证
docker run --rm -d --name demo-app-test -p 3000:3000 registry.example.com/demo-app:1.0.0
curl http://localhost:3000/
docker stop demo-app-test
推送镜像
docker push registry.example.com/demo-app:1.0.0
docker push registry.example.com/demo-app:git-$(git rev-parse --short HEAD)
部署时使用固定版本
docker run -d \
--name demo-app \
-p 3000:3000 \
-e NODE_ENV=production \
registry.example.com/demo-app:1.0.0
这里的原则很简单:
部署永远用明确版本,不用模糊标签。
方案取舍:不是所有项目都该极限瘦身
做镜像优化时,容易陷入一种误区:
“只要镜像还能再小一点,就一定更好。”
其实未必。下面是几个常见取舍点:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单阶段构建 | 简单直接 | 镜像大、风险高 | 本地临时验证 |
| 多阶段 + slim | 兼容性和体积平衡 | 仍比极简镜像大 | 大多数生产服务 |
| 多阶段 + alpine | 体积更小 | 兼容性需验证 | 依赖简单的服务 |
| distroless | 攻击面小 | 排障不方便 | 安全要求高、流程成熟的团队 |
我的建议是:
- 中小团队先落地多阶段构建 + 非 root + 漏洞扫描
- 不要一上来就追求 distroless 或极限最小镜像
- 先把“标准化”做好,再做“极致优化”
总结
如果把这篇文章压缩成几条最值得执行的建议,我会给这份清单:
- 一定用多阶段构建,把构建工具和运行环境拆开
- 一定写
.dockerignore,减少构建上下文 - 优先复制依赖清单,再安装依赖,提高缓存命中
- 最终镜像只保留运行需要的文件
- 使用非 root 用户运行
- 不要依赖
latest,始终发布明确版本 - 把漏洞扫描接入 CI/CD
最后给一个判断标准:
如果你的镜像里还保留编译器、源码、测试文件、包管理缓存、root 权限,那它大概率还没有准备好进入生产环境。
镜像瘦身的目标从来不只是“省几十 MB”,而是让你的构建更快、发布更稳、攻击面更小。
当你把多阶段构建当成默认习惯,而不是“高级技巧”时,Docker 才真正开始服务工程效率。