Docker 多阶段构建与镜像瘦身实战:从构建优化到安全交付
很多团队在刚开始容器化时,往往先把应用“装进 Docker”再说,跑起来就算成功。但一旦进入 CI/CD、环境推广、线上发布阶段,问题会很快冒出来:
- 镜像动不动几百 MB,拉取慢、传输慢
- Dockerfile 一改就全量重建,构建时间长
- 镜像里残留编译工具、包管理器、临时文件,安全面过大
- 运行容器还在用 root,存在额外风险
- 同一个应用,本地能跑,生产却因为基础镜像差异出现问题
我自己早期也踩过典型坑:为了图省事,把 node_modules、源码、构建工具、调试命令全塞进一个镜像里,结果镜像体积大、漏洞扫描一堆告警、上线前还得临时“手工减肥”。后来真正把多阶段构建、缓存策略、最小化运行时镜像串起来之后,构建链路才算稳下来。
这篇文章就从**“为什么镜像会胖”**开始,带你一步步做一个可运行的示例,把镜像体积、构建速度和交付安全一起优化。
背景与问题
一个“能用但不优雅”的 Dockerfile,通常长这样:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
它的问题很集中:
- 构建环境和运行环境混在一起
- 编译工具、依赖下载缓存、源码都进入最终镜像
- 缓存利用差
COPY . .太早,任意源码改动都会导致依赖层失效
- 最终镜像不够精简
- 运行时根本不需要 TypeScript、构建脚本、测试工具
- 安全边界模糊
- 默认 root 用户
- 可能带 shell、包管理器、调试工具
- 交付链条效率低
- 镜像大导致推送、拉取、回滚都慢
从工程视角看,容器镜像不只是“应用打包格式”,它还是:
- 构建产物
- 交付载体
- 安全边界
- 线上可观测与回滚单位
所以,镜像瘦身不是单纯“省磁盘”,而是影响构建效率、发布速度、漏洞数量、运行安全的系统性优化。
前置知识与环境准备
本文示例使用一个简单的 Node.js Web 应用演示,因为它比较典型:有构建依赖、有生产依赖,也容易体现多阶段构建的价值。
环境要求
- Docker 20.10+
- 推荐启用 BuildKit
- 本机可执行:
docker builddocker rundocker image ls
建议先开启 BuildKit:
export DOCKER_BUILDKIT=1
如果你在 Windows PowerShell:
$env:DOCKER_BUILDKIT=1
核心原理
1. 多阶段构建是什么
多阶段构建的核心思想是:把“构建”与“运行”拆开。
- 第一阶段:安装依赖、编译、打包
- 第二阶段:只复制运行所需文件
这样最终镜像里不再包含构建工具链。
flowchart LR
A[源码] --> B[构建阶段 Builder]
B --> C[编译产物 dist]
C --> D[运行阶段 Runtime]
D --> E[最终镜像]
2. 为什么它能瘦身
因为容器镜像是分层的。你在 builder 阶段装了 gcc、make、devDependencies,不代表要把这些层带到 runtime 阶段。
关键点在于:
FROM ... AS builderFROM ... AS runtimeCOPY --from=builder ...
最终镜像只包含 runtime 阶段自己的层,以及从 builder 拷贝进来的必要产物。
3. 镜像变胖的常见来源
flowchart TD
A[镜像过大] --> B[基础镜像过重]
A --> C[把源码和构建工具一起打包]
A --> D[依赖安装策略不合理]
A --> E[未清理缓存/临时文件]
A --> F[上下文过大]
A --> G[复制了无关文件]
常见“增肥项”包括:
- 用
ubuntu、debian做运行时,但应用其实只需最小运行环境 - 没写
.dockerignore - 把
.git、测试文件、文档、日志一起复制进镜像 - npm/pip/apk/apt 缓存残留
- 开发依赖进入生产镜像
- 静态资源构建后,原始源码还留着
4. 缓存为什么重要
构建慢很多时候不是 CPU 不够,而是 Dockerfile 层次写得不合理。
例如:
COPY . .
RUN npm ci
只要任意一个源码文件变了,npm ci 这一层就要重跑。
更好的方式是先复制依赖描述文件:
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
这样源码改动不会让依赖层每次失效。
5. 安全交付为什么要和瘦身一起做
镜像越大,通常意味着:
- 软件包越多
- 漏洞暴露面越大
- 被扫描出的 CVE 越多
- 排查和修复成本越高
所以镜像瘦身和安全交付并不是两件事,而是一件事的两个面。
示例项目结构
下面准备一个最小可运行示例:
docker-node-demo/
├── package.json
├── package-lock.json
├── server.js
├── .dockerignore
└── Dockerfile
package.json
{
"name": "docker-node-demo",
"version": "1.0.0",
"description": "Docker multi-stage demo",
"main": "server.js",
"scripts": {
"start": "node server.js",
"build": "mkdir -p dist && cp server.js dist/server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
server.js
const express = require("express");
const app = express();
app.get("/", (req, res) => {
res.json({
message: "Hello from optimized Docker image",
time: new Date().toISOString()
});
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
.dockerignore
这个文件特别重要,很多人会忽略它。我一开始就吃过亏:本地 node_modules 足足几百 MB,全被作为构建上下文传给 Docker daemon。
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
coverage
dist
实战代码(可运行)
第 1 步:先写一个“普通版” Dockerfile
先看一个不够理想但能跑的版本,便于对比。
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
构建并运行:
docker build -t demo:fat .
docker run --rm -p 3000:3000 demo:fat
访问:
curl http://localhost:3000
查看镜像体积:
docker image ls
这个版本的问题:
npm install会安装所有依赖- 源码、构建产物、依赖都在同一个镜像里
- 没有区分构建依赖和运行依赖
- 缓存命中也一般
第 2 步:改造成多阶段构建
下面是推荐实践版本。
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
构建
docker build -t demo:slim .
运行
docker run --rm -p 3000:3000 demo:slim
核心变化说明
1)先复制 package*.json
COPY package*.json ./
RUN npm ci
这样依赖层能最大化复用缓存。
2)构建完成后裁剪依赖
RUN npm prune --omit=dev
如果项目中有 devDependencies,这一步会把开发依赖去掉。
3)最终镜像只复制必要内容
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
不再复制整个源码目录。
4)切换非 root 用户
USER node
这一步很小,但收益很大,是安全基线的一部分。
第 3 步:进一步优化缓存与构建速度
如果你使用 BuildKit,还可以挂载 npm 缓存。
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
构建过程会更像下面这样:
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker BuildKit
participant Cache as 依赖缓存
participant Registry as 镜像仓库
Dev->>Docker: docker build
Docker->>Cache: 检查 npm 缓存
Cache-->>Docker: 命中已有依赖
Docker->>Docker: 执行构建与裁剪
Docker->>Registry: 推送更小的最终镜像
第 4 步:验证镜像是否真的变小了
查看镜像列表
docker image ls | grep demo
查看镜像历史层
docker history demo:slim
你会看到多阶段构建后的最终镜像层更少、更聚焦。
进入容器检查文件
docker run --rm -it demo:slim sh
在容器内查看:
ls -lah
find . -maxdepth 2 -type f
重点确认:
- 是否只有
dist/、node_modules/、package.json - 是否没有源码测试文件
- 是否没有多余缓存目录
逐步验证清单
如果你想在团队里推广这套方式,我建议每次改 Dockerfile 后都按这个清单走一遍:
- 应用能正常启动
- 健康检查或首页请求正常
- 镜像体积比旧版更小
- 容器内不包含源码仓库、测试文件、构建工具
- 运行用户不是 root
- 构建缓存对依赖层有效
- 漏洞扫描数量没有明显恶化
- CI 中可稳定复现
常见坑与排查
1. COPY --from=builder 路径写错
现象:
- 构建通过不了
- 或运行时报找不到文件,比如
Cannot find module dist/server.js
排查方法:
RUN ls -lah /app
RUN ls -lah /app/dist
可以临时在 builder 阶段打印目录,确认产物路径。
2. .dockerignore 没生效,构建上下文过大
现象:
docker build一开始就很慢- 终端显示 Sending build context 很大
排查点:
.dockerignore是否写在构建上下文根目录- 是否排除了
node_modules、.git、日志、构建产物
这个问题非常常见,而且很“隐形”。很多人只盯着镜像体积,却忘了构建上下文传输本身也耗时。
3. Alpine 兼容性问题
alpine 很小,但不是所有应用都适合它。某些原生依赖、动态库依赖可能在 musl 环境下出问题。
现象:
- 本地构建通过,运行时报动态库错误
- 某些 npm 原生模块无法正常执行
建议:
- 能用
alpine就用 - 遇到兼容性问题时,不要硬扛,改用
debian-slim也是合理选择
边界条件很重要:更小不一定永远更好,稳定性优先。
4. 使用 npm install 导致依赖不稳定
现象:
- 同一份 Dockerfile,不同时间构建结果不一致
- CI 和本地依赖版本不同
建议优先使用:
npm ci
前提是仓库中提交了 package-lock.json。
5. 容器改成非 root 后权限报错
现象:
- 启动时报没有写权限
- 某些目录无法访问
排查思路:
- 运行时是否真的需要写文件
- 需要写的目录是否提前
chown - 应用日志最好输出到 stdout/stderr,而不是写容器文件
如果确实要写临时目录,可在构建阶段处理权限。
安全/性能最佳实践
1. 使用最小必要基础镜像
优先级不是固定的,但一般可参考:
- 运行纯静态产物:
nginx:alpine或更小运行时 - Node 应用:
node:alpine或node:slim - 有兼容性要求:
debian-slim
不要默认拿大而全的基础镜像做运行时。
2. 只把“运行必需品”放进最终镜像
最终镜像尽量只包括:
- 可执行文件或编译产物
- 生产依赖
- 必要配置文件
不要包括:
- 测试代码
- 构建脚本
- 包管理缓存
- 调试工具
- Git 元数据
3. 固定基础镜像版本
不要写这种:
FROM node:latest
建议写明确版本,例如:
FROM node:18.19-alpine
这样能减少“今天能跑,明天挂了”的不确定性。
4. 使用非 root 用户运行
这是非常值得坚持的一条:
USER node
如果是自定义应用,也可以创建专用用户。
RUN addgroup -S app && adduser -S app -G app
USER app
5. 做漏洞扫描,但别只盯着扫描结果数字
安全扫描是必须的,但不要陷入“漏洞数越少越好”的机械指标。更重要的是:
- 漏洞是否可利用
- 是否暴露在运行路径上
- 是否有修复版本
- 是否影响生产环境
镜像瘦身的价值之一,就是减少无关包,自然也会减少无意义告警。
6. 在 CI/CD 中分层优化
推荐流水线思路:
flowchart LR
A[代码提交] --> B[构建阶段]
B --> C[单元测试]
C --> D[镜像构建]
D --> E[安全扫描]
E --> F[推送镜像仓库]
F --> G[部署发布]
建议在 CI 中做到:
- 构建与测试分离
- 镜像构建使用多阶段
- 安全扫描在推送前或推送后自动执行
- 镜像标签使用 commit sha 或语义版本
- 重要环境禁止使用
latest
7. 善用标签与不可变交付
不要只推:
myapp:latest
更推荐:
myapp:1.3.2
myapp:git-8f3c2d1
这样回滚更直接,问题定位也更快。
8. 控制层数,但不要为了“少层”牺牲可维护性
有些人会极端地把很多命令塞到一个 RUN 里。确实能减少层数,但 Dockerfile 可读性会明显变差。
经验上:
- 清理缓存的命令适合合并
- 不同语义步骤适当拆开,便于维护和排障
优化不是比赛,维护性也要算进去。
一个更贴近生产的 Dockerfile 参考
下面给一个更完整一点的模板,适合 Node 服务类应用参考:
# syntax=docker/dockerfile:1.4
FROM node:18.19-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
FROM node:18.19-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
对应构建与运行命令:
docker build -t myapp:1.0.0 .
docker run --rm -p 3000:3000 myapp:1.0.0
什么时候不必过度追求极致瘦身
这点我想单独说一下。不是所有项目都值得为“再省 20MB”投入很多工程时间。
你可以优先优化这些场景:
- 镜像超过几百 MB,发布明显变慢
- CI 构建频繁,缓存效率低
- 安全扫描告警很多
- 边缘环境、带宽受限、节点扩缩容频繁
而下面这些场景可以适度即可:
- 内部工具,部署频率低
- 构建链路简单,镜像体积已可接受
- 为兼容性必须使用较完整运行时
所以正确目标不是“最小镜像”,而是足够小、足够快、足够安全、足够稳定。
总结
如果把 Docker 多阶段构建浓缩成一句话,那就是:
让构建环境服务于产物生成,而不是进入最终交付物。
你可以把今天的内容落成几个可执行动作:
- 先补
.dockerignore - 把 Dockerfile 改成多阶段构建
- 先复制依赖描述文件,再安装依赖
- 最终镜像只保留运行必需文件
- 使用非 root 用户运行
- 在 CI 中加入镜像扫描与版本化标签
如果你现在手里的 Dockerfile 还是“一把梭 COPY . .”,那就从这篇文章里的示例开始改。通常不需要大动应用代码,就能看到明显收益:镜像更小、构建更快、交付更稳、安全面更清晰。
这类优化最适合在项目还不太复杂的时候尽早建立规范。因为越往后,镜像里“历史包袱”越多,清理成本就越高。早点做,回报很直接。