Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速、体积优化与安全加固指南
很多团队刚开始用 Docker 时,重点往往只有一件事:“先跑起来再说。”
结果项目能跑是能跑,但镜像越来越大,构建越来越慢,安全扫描一片红。
我自己最早也这么干过:一个 Dockerfile 里从装编译工具、装依赖、拷贝源码、打包、运行,全写在一起。短期很省事,长期代价很明显:
- 镜像体积动不动几百 MB,甚至上 GB
- CI 构建耗时长,推送和拉取都慢
- 镜像里残留编译器、缓存、包管理器元数据
- 安全扫描暴露大量不必要的漏洞面
- 层缓存设计不好,一改一行代码就全量重建
这篇文章不讲“概念式入门”,而是带你从问题出发,用多阶段构建把构建链路拆干净,再结合缓存、基础镜像选择、运行时最小化和权限收缩,真正做到:
- 构建更快
- 镜像更小
- 运行更安全
- 排障更清晰
背景与问题
先看一个典型的“能用但不优雅”的单阶段 Dockerfile:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
这类写法的问题非常集中:
-
构建依赖和运行依赖混在一起
npm install可能把开发依赖、编译工具链都带进最终镜像。 -
源码变动导致缓存失效严重
COPY . .放得太早,只要业务代码改动,依赖层也会重建。 -
基础镜像偏大
node:18完整镜像虽然方便,但通常包含很多运行时并不需要的内容。 -
安全边界模糊
默认 root 用户运行,一旦容器被突破,后果更严重。 -
构建产物与环境耦合
编译阶段残留在最终镜像中,导致体积和攻击面都放大。
多阶段构建要解决的,本质上就是一句话:
让“构建环境”和“运行环境”彻底分离。
前置知识与环境准备
建议你的环境至少具备:
- Docker 20.10+
- 开启 BuildKit
- 一个可运行的 Node.js 示例项目
- 会使用基础命令:
docker build、docker run、docker images
建议先开启 BuildKit:
export DOCKER_BUILDKIT=1
如果你用 Docker Desktop,一般默认已启用。
核心原理
1. 什么是多阶段构建
多阶段构建允许在一个 Dockerfile 中定义多个 FROM 阶段。
前面的阶段用来编译、打包、测试;最后一个阶段只保留运行所需的最小内容。
比如:
builder阶段:安装依赖、执行构建runner阶段:只复制构建产物和必要运行依赖
这样最终镜像不会包含编译器、缓存目录和中间文件。
2. 为什么它能瘦身
因为 Docker 最终只保留最后阶段的内容。
前面阶段生成的内容,只有显式 COPY --from=... 的部分才会进入最终镜像。
3. 为什么它能提速
多阶段构建本身不是“魔法加速器”,真正加速来自两点:
- 更好的分层设计,让缓存命中率更高
- 把频繁变化的源码层放后面,把稳定依赖层放前面
4. 为什么它更安全
运行阶段可以做到:
- 使用更小的基础镜像
- 不带编译工具链
- 不带 shell(视场景)
- 以非 root 用户运行
- 只暴露必要端口
- 减少已知漏洞包数量
一张图看懂构建流转
flowchart LR
A[源码与依赖清单] --> B[builder 阶段]
B --> C[安装依赖]
C --> D[执行构建/打包]
D --> E[生成 dist]
E --> F[runner 阶段]
F --> G[仅复制 dist 与生产依赖]
G --> H[最终镜像更小更安全]
镜像层与缓存命中的关键关系
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant Cache as 层缓存
Dev->>Docker: 提交 Dockerfile + 源码
Docker->>Cache: 检查 package.json / lock 文件层
alt 依赖未变化
Cache-->>Docker: 命中依赖缓存
else 依赖变化
Docker->>Docker: 重新安装依赖
end
Docker->>Cache: 检查源码 COPY 层
Dev->>Docker: 修改业务代码
Docker->>Docker: 仅重建源码相关层
Docker-->>Dev: 输出新镜像
实战代码(可运行)
下面我们用一个 Node.js Web 项目做示例。即便你的主项目是 Go、Java 或 Python,思路也是一样的:编译在 builder,运行在 runner。
示例目录结构
demo-app/
├── package.json
├── package-lock.json
├── server.js
├── .dockerignore
└── Dockerfile
1)准备一个最小可运行项目
package.json
{
"name": "demo-app",
"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();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({
message: 'hello from docker multi-stage build',
time: new Date().toISOString()
});
});
app.listen(port, () => {
console.log(`server listening on ${port}`);
});
2)先写一个“不推荐但常见”的版本
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个版本能用,但不够好。
3)改造成多阶段构建版本
Dockerfile
# syntax=docker/dockerfile:1.6
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules /app/node_modules
COPY . .
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist /app/dist
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
4)配套 .dockerignore
这个文件非常重要,很多人会忽略。
它直接决定你发送给 Docker daemon 的构建上下文有多大。
.dockerignore
node_modules
npm-debug.log
.git
.gitignore
Dockerfile
README.md
dist
.env
coverage
如果没有 .dockerignore,你本地的 node_modules、.git、测试结果、构建产物都可能被打包进上下文,不仅拖慢构建,还会污染缓存。
逐步验证清单
构建镜像
docker build -t demo-app:multi .
运行容器
docker run -p 3000:3000 demo-app:multi
访问接口
curl http://localhost:3000
预期输出:
{"message":"hello from docker multi-stage build","time":"2024-04-04T00:00:00.000Z"}
查看镜像体积
docker images | grep demo-app
查看镜像历史层
docker history demo-app:multi
你会看到最终镜像中只保留了运行所需内容,而不是整个构建过程的所有杂物。
进一步优化:让缓存更稳定
上面的版本已经不错了,但还可以继续优化。
一个经验是:先复制依赖清单,再安装依赖,最后复制源码。
为什么?因为:
package.json/package-lock.json变化频率通常低于业务源码- 这样业务代码变更时,依赖层仍可复用
比如下面这种写法是缓存友好的:
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
如果你写成:
COPY . .
RUN npm ci
RUN npm run build
那么只要任意源码变化,npm ci 那层也会失效。
BuildKit 缓存挂载:进一步提速
如果你的 CI 或本地构建频繁,BuildKit 的缓存挂载很值得上。
# syntax=docker/dockerfile:1.6
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
这样 npm 缓存可以复用,尤其在 CI 中能明显减少依赖下载时间。
注意:缓存挂载加速的是下载和构建过程,不会把缓存目录直接放进最终镜像。
更适合生产的版本
下面给一个更贴近生产使用的版本:包含健康检查、权限控制和更清晰的阶段分离。
# syntax=docker/dockerfile:1.6
FROM node:18-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 base AS builder
COPY --from=deps /app/node_modules /app/node_modules
COPY . .
RUN npm run build
FROM base AS prod-deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
&& npm cache clean --force
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=builder /app/dist /app/dist
COPY package.json ./
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:3000/ || exit 1
CMD ["node", "dist/server.js"]
这个版本的思路更清楚:
base:统一工作目录deps:安装完整依赖,供构建使用builder:执行打包prod-deps:安装仅生产依赖runner:最小化运行环境
多阶段构建的常见模式
flowchart TD
A[源码] --> B[deps 安装依赖]
B --> C[builder 编译/测试]
C --> D{是否需要生产依赖?}
D -->|是| E[prod-deps 安装生产依赖]
D -->|否| F[直接进入 runner]
E --> G[runner 最终运行镜像]
F --> G
适用于不同语言的常见策略:
- Go:builder 用 golang 镜像编译,runner 用 distroless 或 alpine
- Java:builder 用 maven/gradle,runner 用 jre 或更小的 runtime
- Python:builder 编译 wheel,runner 只装 wheel 和运行时依赖
- Node.js:builder 安装全量依赖打包,runner 只保留生产依赖和产物
常见坑与排查
这一节很关键,很多人不是不会写多阶段,而是写了之后“怎么构建失败了”。
坑 1:COPY --from=builder 路径不对
现象:
COPY failed: stat /var/lib/docker/...: no such file or directory
原因通常是 builder 阶段输出目录和你复制的目录不一致。
比如构建产物实际在 /app/build,你却复制 /app/dist。
排查方式:
RUN ls -la /app
RUN ls -la /app/dist
在 builder 阶段临时加上这些命令,先确认文件真的存在。
坑 2:npm ci 失败
现象:
npm ERR! cipm can only install packages when package-lock.json is present
原因:
- 缺少
package-lock.json package.json与 lock 文件不匹配
建议:
- 在 CI 和生产构建中优先使用
npm ci - 把 lock 文件纳入版本控制
- 不要本地乱改依赖后忘记提交 lock 文件
坑 3:Alpine 镜像并不总是最优
很多人一上来就说“用 alpine 肯定最小”。这句话只对了一半。
Alpine 的优势:
- 体积小
但它也有边界条件:
- 使用
musl libc,某些原生模块兼容性可能有坑 - 某些编译依赖安装更复杂
- 调试体验不如 Debian/Ubuntu 系基础镜像直接
如果你项目里有复杂原生依赖,比如某些图像处理、加密、数据库驱动库,不要机械地追求 alpine。
我自己踩过的坑就是:镜像是小了几十 MB,但构建脚本复杂了一倍,排障成本远超收益。
坑 4:本地 node_modules 干扰构建
如果 .dockerignore 没写好,本地 node_modules 可能被复制进去,导致:
- 构建上下文非常大
- 本地平台依赖污染容器内环境
- Linux 容器里混入 macOS/Windows 依赖产物
排查命令:
docker build --no-cache -t demo-app:test .
同时检查构建日志中的上下文大小是否异常。
坑 5:非 root 用户运行后权限不足
现象:
- 容器启动时报文件权限错误
- 应用无法写日志或临时文件
处理方式:
RUN chown -R appuser:appgroup /app
USER appuser
如果程序需要写某个目录,记得提前创建并授权。
坑 6:健康检查命令不可用
比如你在极简镜像里用了:
HEALTHCHECK CMD curl -f http://localhost:3000/ || exit 1
但镜像里根本没有 curl。
要么安装一个轻量工具,要么换成镜像内已有命令,比如 wget,或者用应用自身暴露的探针机制。
安全/性能最佳实践
这一部分我建议你直接当作“上线前检查表”。
1. 运行时镜像尽量最小化
原则:
- 最终镜像只保留运行必需文件
- 不保留编译工具、测试文件、缓存和源码(视场景)
- 能拆阶段就拆,不要图省事混在一起
2. 使用非 root 用户
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
这是性价比非常高的一步。
它不能代替完整安全方案,但能有效降低容器被利用后的破坏范围。
3. 固定基础镜像版本
不要总写:
FROM node:latest
建议写明确版本,最好细到次版本甚至 digest:
FROM node:18.20-alpine
更稳定,也更利于问题回溯。
4. 减少层中的无效文件
例如安装系统包时注意清理缓存。
Debian/Ubuntu 系通常这样处理:
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
5. 利用 .dockerignore
这是最容易被忽视、但收益非常直接的一项优化。
至少排除:
.gitnode_modules- 本地构建目录
- 测试报告
.env- IDE 配置文件
6. 在 CI 中做镜像扫描
推荐把镜像扫描集成到流水线中。
例如用 Trivy:
trivy image demo-app:multi
这样你能尽早发现基础镜像或依赖中的高危漏洞。
7. 按需使用 Distroless
如果你的应用运行时不需要 shell、包管理器、调试工具,可以考虑 Distroless。
优点是更小、更少攻击面;缺点是调试难度更高。
适合:
- 已经比较稳定的生产服务
- 有成熟可观测性方案的团队
不太适合:
- 还在频繁排障的早期项目
- 需要经常进入容器做临时诊断的场景
8. 不要为了小而小
镜像瘦身是手段,不是 KPI。
如果为了省 20MB,结果让维护复杂度上升、构建不稳定、排障困难,那就得不偿失。
一个很实用的判断标准是:
- 是否明显减少构建时间
- 是否明显降低传输成本
- 是否减少漏洞数量
- 是否没有显著增加维护成本
符合这些,再推进。
如何评估优化是否真的有效
不要只凭感觉。建议至少看这几项指标:
镜像体积
docker images
构建耗时
CI 中记录:
- 总构建时间
- 依赖安装时间
- 推送镜像时间
层缓存命中率
观察构建日志,看哪些步骤频繁被复用,哪些步骤总是在重跑。
漏洞数量
用扫描工具比较优化前后:
- Critical
- High
- Medium
启动速度
镜像更小不一定启动更快,但网络拉取通常会更快。
在跨地域部署和弹性扩缩容场景里,这一点尤其明显。
一个简化的优化路线图
如果你现在项目里的 Dockerfile 还比较“原始”,我建议按这个顺序改:
- 先加
.dockerignore - 调整
COPY顺序,优先缓存依赖层 - 改成多阶段构建
- 运行时只保留生产依赖
- 切换为非 root 用户
- 固定基础镜像版本
- 接入漏洞扫描
- 视情况尝试 Distroless 或更小 runtime
这个顺序的好处是:收益逐步可见,风险可控。
总结
Docker 多阶段构建不是“高级技巧”,它应该成为生产构建的默认姿势。
你可以把这篇文章的核心记成三句话:
- 构建环境和运行环境一定要分离
- 缓存友好的分层设计,比盲目换小镜像更重要
- 镜像瘦身的终点不是数字更小,而是交付更快、风险更低、维护更稳
如果你现在就准备落地,我建议先从下面这个最小行动清单开始:
- 给项目补上
.dockerignore - 把 Dockerfile 改成 builder + runner 两阶段
- 把
COPY package*.json放到COPY . .前面 - 运行阶段只装生产依赖
- 改成非 root 用户运行
- 在 CI 里增加一次镜像漏洞扫描
这些动作不复杂,但通常就能解决 80% 的镜像臃肿和构建低效问题。
当你把这些基础做好之后,再去谈更激进的优化,比如 Distroless、跨平台构建缓存、远程缓存复用,才会事半功倍。