Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速、体积优化与安全加固
很多团队第一次把应用容器化时,往往能“跑起来”,但镜像会越来越胖,构建也越来越慢,安全风险还不低。
我自己就踩过类似的坑:一个看起来不复杂的 Node.js 服务,最终镜像接近 1GB,CI 每次构建都像在“搬家”,上线前安全扫描还扫出一堆不该存在的构建工具链。
这篇文章不讲太多概念堆砌,而是从一个中级开发者最常见的场景出发,带你把一个“能用但不优雅”的 Dockerfile,逐步改造成:
- 构建更快
- 镜像更小
- 运行更安全
- 更适合 CI/CD 缓存复用
重点会放在 Docker 多阶段构建、镜像瘦身 和 容器安全加固 的组合实践上。
背景与问题
先看一个常见但不理想的 Dockerfile。
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
它的问题非常典型:
- 构建依赖和运行依赖混在一起
npm install时需要的一些工具、缓存、编译依赖,都会留在最终镜像里。
- 源码全量复制过早
COPY . .放在前面,任何一个文件变化都会导致依赖层缓存失效。
- 镜像体积偏大
- 基础镜像、依赖缓存、测试文件、源码、构建产物全都打包进去。
- 默认 root 用户运行
- 这在生产环境里并不理想。
- 安全面扩大
- 不必要的软件包越多,漏洞暴露面越大。
如果你的项目还包含前端打包、Go 编译、Java 构建、Python wheel 构建等步骤,这些问题会进一步放大。
前置知识与环境准备
建议你提前准备:
- Docker 20.10+
- 开启 BuildKit(推荐)
- 一个 Node.js 示例项目
- 基本理解 Docker layer/caching
启用 BuildKit
Linux/macOS:
export DOCKER_BUILDKIT=1
也可以直接这样执行:
DOCKER_BUILDKIT=1 docker build -t demo-app .
核心原理
多阶段构建的核心思路其实很朴素:
把“构建环境”和“运行环境”拆开,只把运行真正需要的内容复制到最终镜像。
1. 构建阶段与运行阶段分离
比如一个 Node.js 应用:
- 构建阶段
- 安装完整依赖
- 执行构建
- 生成
dist/
- 运行阶段
- 只保留
dist/ - 只安装生产依赖
- 不带编译工具链、不带源码、不带测试文件
- 只保留
2. 利用层缓存优化构建速度
Docker 的缓存粒度是“层”。
所以 Dockerfile 写法会直接影响构建速度。
典型优化思路:
- 先复制
package.json、package-lock.json - 先安装依赖
- 再复制业务代码
这样当你只改了业务代码时,依赖层还能复用缓存。
3. 基础镜像越小,攻击面通常越小
不是说越小一定越安全,但通常:
- 软件包越少
- 工具链越少
- shell 越少
- 包管理器越少
最终暴露的攻击面也就越小。
4. 最终镜像只保留“运行时最小集合”
这通常包括:
- 二进制文件或构建产物
- 必需运行时依赖
- 最小权限用户
- 必需环境变量和入口命令
flowchart LR
A[源码与依赖清单] --> B[构建阶段 builder]
B --> C[安装依赖]
C --> D[执行编译/打包]
D --> E[产出 dist 或二进制]
E --> F[运行阶段 runner]
F --> G[仅复制运行所需文件]
G --> H[更小更安全的生产镜像]
一个坏例子到好例子的演进
为了更贴近实际,我们用一个 Node.js 应用来演示。
假设项目结构如下:
.
├── src
│ └── index.js
├── package.json
├── package-lock.json
├── .dockerignore
└── Dockerfile
示例应用代码
package.json:
{
"name": "demo-app",
"version": "1.0.0",
"description": "demo app for docker multi-stage build",
"main": "dist/index.js",
"scripts": {
"build": "mkdir -p dist && cp -r src/* dist/",
"start": "node dist/index.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
src/index.js:
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.json({
message: 'hello from docker multi-stage build',
time: new Date().toISOString()
});
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`server listening on ${port}`);
});
实战代码(可运行)
第一步:先写一个基础版多阶段 Dockerfile
# syntax=docker/dockerfile:1
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src ./src
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/index.js"]
这个版本已经比单阶段好在哪?
- 构建和运行分离
- 构建产物从
builder拷贝到runner - 运行镜像不再包含完整源码
npm ci更适合 CI 场景,依赖安装更可控
构建与运行
docker build -t demo-app:basic .
docker run --rm -p 3000:3000 demo-app:basic
验证:
curl http://localhost:3000
第二步:进一步优化缓存命中率
如果构建时经常因为源码小改动导致依赖重装,那基本就是 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 package*.json ./
COPY src ./src
RUN npm run build
FROM node:18-alpine AS runner
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
EXPOSE 3000
CMD ["node", "dist/index.js"]
这里做了什么?
- 单独拆出
deps阶段,依赖层更容易复用 - 用 BuildKit 的缓存挂载加速 npm 下载
- 业务代码变化不会影响依赖安装层
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant Cache as Layer/BuildKit Cache
participant Registry as npm Registry
Dev->>Docker: docker build
Docker->>Cache: 检查 package-lock 对应缓存
alt 命中缓存
Cache-->>Docker: 返回依赖层
else 未命中
Docker->>Registry: 下载依赖
Registry-->>Docker: 返回依赖包
Docker->>Cache: 保存依赖缓存
end
Docker->>Docker: 复制源码并构建 dist
Docker->>Docker: 组装 runner 最终镜像
第三步:增加镜像瘦身动作
仅做多阶段还不够,很多镜像肥胖来自“上下文污染”和“多余文件被 COPY 进去”。
.dockerignore 非常关键
创建 .dockerignore:
node_modules
npm-debug.log
dist
.git
.gitignore
Dockerfile
README.md
coverage
.env
.env.*
为什么它这么重要?
因为 docker build 时,Docker 会先把构建上下文发送给守护进程。
如果你把 node_modules、.git、测试报告、日志文件都发过去:
- 传输慢
- 缓存容易失效
- 镜像构建上下文变大
- 可能误带敏感信息
我见过最夸张的情况是:不是镜像本身太大,而是 .git 历史和本地构建垃圾文件把上下文拖爆了。
第四步:做运行时安全加固
现在继续把 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 package*.json ./
COPY src ./src
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
&& npm cache clean --force \
&& addgroup -S appgroup \
&& adduser -S appuser -G appgroup \
&& chown -R appuser:appgroup /app
COPY --from=builder /app/dist ./dist
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
这里重点看三件事
1)非 root 用户运行
USER appuser
这是非常值得保留的安全默认项。
2)生产环境变量明确声明
ENV NODE_ENV=production
这会影响很多框架和依赖的运行行为。
3)不把构建工具链带进最终镜像
最终镜像只包含:
- 生产依赖
dist- 运行用户
- 启动命令
逐步验证清单
每做完一次优化,不要只“感觉更好了”,最好做可验证检查。
1. 看镜像体积
docker images | grep demo-app
2. 看镜像层历史
docker history demo-app:basic
3. 进入容器确认运行内容
docker run --rm -it demo-app:basic sh
检查:
ls -lah
whoami
4. 验证是否为非 root 用户
docker run --rm demo-app:basic whoami
5. 检查是否只保留必要文件
docker run --rm -it demo-app:basic sh -c "find /app -maxdepth 2 -type f | sort"
6. 对比构建耗时
第一次构建:
time DOCKER_BUILDKIT=1 docker build -t demo-app:test .
修改 src/index.js 再次构建:
time DOCKER_BUILDKIT=1 docker build -t demo-app:test .
如果依赖层复用了,第二次通常会明显更快。
常见坑与排查
坑 1:多阶段构建后,应用跑不起来
典型现象
容器启动后报错:
Error: Cannot find module ...
常见原因
- 运行阶段没有安装生产依赖
- 只复制了
dist,但运行时还依赖配置文件或静态资源 - 构建产物路径不对
排查方法
docker run --rm -it demo-app:basic sh
find /app -maxdepth 3 -type f | sort
重点确认:
dist/index.js是否存在node_modules是否存在- 静态资源是否被复制到最终镜像
坑 2:缓存一直失效,构建还是很慢
常见原因
COPY . .太早.dockerignore没写好package-lock.json经常被无意义改动- CI 没有开启 BuildKit 或缓存策略
建议排查顺序
- 看 Dockerfile 中
COPY顺序 - 看上下文里是否带了无关文件
- 看依赖文件是否稳定
- 看 CI 是否启用了 cache-from / BuildKit
坑 3:Alpine 很小,但某些依赖安装失败
为什么会这样?
alpine 使用 musl libc,而有些 Node 原生模块、Python 扩展、系统依赖更偏向 glibc 环境。
这时你可能会遇到:
- 编译失败
- 运行时报动态库问题
- 性能或兼容性异常
怎么选?
- 追求极致小体积:优先试
alpine - 追求兼容性和稳定性:可考虑
debian-slim
不要为了“理论更小”硬上 Alpine,结果在业务里被兼容性反噬。
坑 4:镜像小了,但安全扫描还是一堆漏洞
原因可能是
- 基础镜像本身就有 CVE
- 依赖版本老旧
- 运行镜像仍保留不必要包
- 镜像没及时更新重建
排查建议
先看基础镜像:
docker pull node:18-alpine
再做漏洞扫描,例如使用 Trivy:
trivy image demo-app:basic
如果漏洞主要来自基础镜像,就不要只盯着业务代码。
坑 5:容器退出太快
现象
docker run 后容器秒退。
常见原因
CMD写错- 主进程没常驻
- 应用启动失败但日志没看到
查看日志
docker logs <container_id>
也可以前台运行:
docker run --rm -p 3000:3000 demo-app:basic
flowchart TD
A[构建/运行异常] --> B{是构建失败还是运行失败}
B -->|构建失败| C[检查 Dockerfile COPY 顺序]
C --> D[检查依赖安装日志]
D --> E[检查 Alpine 兼容性]
B -->|运行失败| F[检查 dist 与 node_modules]
F --> G[查看容器日志]
G --> H[进入容器确认文件与权限]
安全/性能最佳实践
下面这部分我建议你当成日常 checklist 来用。
1. 优先使用多阶段构建
适用范围很广:
- Node.js 打包
- Go 编译
- Java Maven/Gradle 构建
- Python wheel 构建
- 前端静态资源构建
原则只有一句:
能不进最终镜像的东西,就别进去。
2. 固定基础镜像版本,避免“漂移”
不要只写:
FROM node:latest
更推荐:
FROM node:18-alpine
如果你对可重复构建要求更高,可以进一步固定 digest。
3. 使用 npm ci 而不是 npm install
在 CI/CD 和镜像构建场景中:
npm ci更快- 更可预测
- 严格依赖 lock 文件
4. 让依赖安装层尽可能稳定
推荐顺序:
COPY package*.json ./
RUN npm ci
COPY src ./src
而不是:
COPY . .
RUN npm install
后者几乎是缓存杀手。
5. 控制构建上下文
.dockerignore 要当成必须项,不是可选项。
尤其要排除:
.gitnode_modules- 本地缓存
- 测试报告
- 临时文件
- 密钥与
.env
6. 用非 root 用户运行
这是容器安全基线之一。
USER appuser
如果应用需要写文件,提前处理好目录权限,而不是上线后临时用 root 顶着跑。
7. 定期扫描镜像漏洞
推荐把镜像扫描放进 CI:
- Trivy
- Grype
- Docker Scout
至少做到:
- 构建时扫描
- 发布前扫描
- 基础镜像定期刷新
8. 不要在镜像里保存敏感信息
不要这样做:
ENV DB_PASSWORD=123456
更好的方式:
- 运行时注入环境变量
- 使用 Secret 管理
- 在编排平台中托管凭据
9. 根据场景选择基础镜像
一个实用判断表:
| 场景 | 推荐 |
|---|---|
| 极致小体积、依赖简单 | alpine |
| 兼容性优先 | debian-slim |
| 单二进制程序 | scratch 或 distroless |
| 安全要求更高 | distroless |
如果你的服务是 Go 静态编译产物,distroless 或 scratch 往往非常香。
如果是 Node/Python 这种运行时依赖较多的应用,就要兼顾调试成本与兼容性。
10. 把“可观测”和“可调试”留在流程里,而不是留在镜像里
很多人为了方便排障,会想把 curl、bash、vim 都装进生产镜像。
短期看方便,长期看会让镜像越来越重、风险越来越大。
更好的思路是:
- 生产镜像保持最小化
- 调试通过临时 debug 容器完成
- 日志、指标、trace 走外部体系
一个更接近生产可用的最终版本
下面给出一个比较平衡的版本:兼顾缓存、体积和安全。
# 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 package*.json ./
COPY src ./src
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
&& npm cache clean --force \
&& addgroup -S appgroup \
&& adduser -S appuser -G appgroup
COPY --from=builder /app/dist ./dist
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
搭配的 .dockerignore:
node_modules
dist
coverage
.git
.env
.env.*
npm-debug.log
README.md
Dockerfile
构建:
DOCKER_BUILDKIT=1 docker build -t demo-app:prod .
运行:
docker run --rm -p 3000:3000 demo-app:prod
什么时候不必过度优化?
这点也很重要。不是所有项目都要追求极限瘦身。
以下场景可以先“够用就好”:
- 内部工具,构建频率低
- 镜像体积对交付影响不大
- 团队当前更需要稳定交付而非极致压缩
- Alpine 兼容性带来的成本大于收益
换句话说:
优化是为业务服务,不是为了把镜像数字卷到最好看。
建议优先级通常是:
- 先做多阶段构建
- 再做缓存优化
- 再做最小权限和漏洞扫描
- 最后再考虑更激进的镜像极致瘦身
总结
如果你只记住这篇文章里的三件事,我建议是这三条:
- 构建环境和运行环境分离
- 多阶段构建是镜像瘦身的核心抓手。
- Dockerfile 顺序决定缓存效率
- 先拷依赖清单,再装依赖,最后拷源码。
- 瘦身不只是为了体积,更是为了安全和交付效率
- 更小的镜像通常意味着更少的攻击面、更快的拉取与部署。
对中级开发者来说,最实用的落地路径不是一步到位追求完美,而是按下面顺序逐步演进:
- 把单阶段改成多阶段
- 补
.dockerignore - 调整
COPY顺序 - 用 BuildKit 缓存
- 改为非 root 运行
- 加入漏洞扫描
只要这几步走稳,你的 Docker 镜像质量通常就会有一个肉眼可见的提升。