Docker 多阶段构建与镜像瘦身实战:从“能跑”到“跑得快、传得快、出问题少”
很多团队第一次写 Dockerfile,目标都很朴素:先把服务跑起来。
但项目一进迭代期,问题就会一起冒出来:
- 镜像 1GB 起步,CI 推送和拉取都很慢
- 构建依赖、编译工具链全被打进生产镜像
- 一个小改动就全量重建,缓存几乎不起作用
- 容器默认 root 运行,安全审计一查一个准
- 线上排障时发现镜像层太乱,不知道哪些文件到底有没有必要
我自己早期也踩过一个很典型的坑:Go 服务明明编译后就一个二进制文件,结果镜像还是接近 800MB。原因很简单——我把整个构建环境、包管理缓存、源码目录都一起塞进了运行时镜像。
这篇文章不讲抽象概念堆砌,而是带你从一个“普通但常见”的 Dockerfile 出发,逐步做到三件事:
- 用多阶段构建拆分编译与运行环境
- 利用构建缓存提升 CI/CD 速度
- 建立可落地的镜像安全基线
适合已经会写 Dockerfile,但希望把镜像质量再提升一个层级的中级开发者。
前置知识与环境准备
你至少需要:
- 会使用
docker build、docker run - 理解 Docker 镜像层和容器的基本概念
- 本地已安装:
- Docker 20+
- 可选:BuildKit(推荐开启)
建议先开启 BuildKit,它对缓存、挂载和构建体验帮助很大。
export DOCKER_BUILDKIT=1
如果你使用 Docker Desktop,新版本通常默认已启用。
背景与问题
先看一个“很常见但不够好”的 Node.js Dockerfile:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]
它的问题并不少:
-
构建环境和运行环境混在一起
npm install、源码、构建工具全留在最终镜像里
-
缓存利用不佳
COPY . .放得太早,只要代码有变化,npm install就会重新执行
-
镜像体积大
- 如果项目包含测试文件、文档、Git 元数据,也会一起打进去
-
默认 root 运行
- 安全基线太弱
-
依赖边界模糊
- 开发依赖和生产依赖可能都进入运行时
这些问题在本地可能不明显,但到了 CI/CD、Kubernetes 或跨地域部署时,会被无限放大。
核心原理
Docker 多阶段构建的核心思想可以概括成一句话:
把“构建需要的东西”和“运行需要的东西”分开。
编译、打包、测试通常需要很多工具链;而线上运行往往只需要:
- 编译后的产物
- 必要的运行时依赖
- 最小权限配置
也就是说,最终镜像不应该承载“整个开发现场”。
多阶段构建的工作方式
flowchart LR
A[源码与依赖定义] --> B[builder 阶段<br/>安装依赖/编译]
B --> C[生成构建产物]
C --> D[runtime 阶段<br/>仅复制必要文件]
D --> E[最终生产镜像]
镜像瘦身的几个抓手
镜像变大,通常来自这几类内容:
- 基础镜像过大
- 构建工具链留在最终镜像
- 包管理缓存没清理
- 无关文件被
COPY进去 - 运行时带了开发依赖
因此瘦身通常靠以下方法组合:
- 选更合适的基础镜像
- 使用多阶段构建
- 合理安排 Dockerfile 指令顺序,最大化缓存命中
- 配置
.dockerignore - 仅复制运行必需文件
- 非 root 用户运行
- 尽量固定依赖版本与基础镜像摘要
构建缓存为什么会快很多
Docker 是按层缓存的。某一层内容不变,后续构建就能复用。
对于 Node 项目,正确顺序通常是:
- 先复制
package.json/package-lock.json - 安装依赖
- 再复制业务代码
- 再执行构建
这样业务代码变更时,不会让依赖安装层失效。
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant Cache as 构建缓存
Dev->>Docker: COPY package*.json
Docker->>Cache: 检查依赖层缓存
Cache-->>Docker: 命中/未命中
Dev->>Docker: RUN npm ci
Dev->>Docker: COPY src/
Dev->>Docker: RUN npm run build
Docker-->>Dev: 输出最终镜像
实战代码(可运行)
下面我们用一个简单的 Node.js 示例,完整走一遍从“普通写法”到“可上线写法”。
示例项目结构
demo-node-app/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
└── src
└── index.js
示例代码
package.json
{
"name": "demo-node-app",
"version": "1.0.0",
"description": "demo for docker multi-stage build",
"main": "src/index.js",
"scripts": {
"build": "mkdir -p dist && cp src/index.js dist/index.js",
"start": "node dist/index.js"
},
"dependencies": {
"express": "4.18.2"
}
}
src/index.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',
hostname: process.env.HOSTNAME || 'unknown'
});
});
app.listen(port, () => {
console.log(`server listening on ${port}`);
});
先看一个改造前版本
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "start"]
这个版本能跑,但不够“生产化”。
改造成多阶段构建版本
Dockerfile
# syntax=docker/dockerfile:1.4
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 deps AS build
COPY src ./src
RUN npm run build
FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["npm", "run", "start"]
这个版本做了几件关键事情:
deps阶段只负责安装依赖build阶段负责构建产物runtime阶段只拿运行需要的内容- 使用 Alpine 作为较轻量基础镜像
- 使用非 root 用户运行
- 使用 BuildKit 缓存 npm 包目录
配置 .dockerignore
这是很多人容易忽略、但收益非常直接的文件。
.dockerignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
coverage
dist
*.md
作用很简单:减少构建上下文。
如果不加它,Docker 在构建时会把很多无关文件一起发送给 daemon,这本身就会拖慢构建,并可能污染镜像层。
构建与运行
构建镜像
docker build -t demo-node-app:1.0 .
运行容器
docker run --rm -p 3000:3000 demo-node-app:1.0
验证接口
curl http://localhost:3000
预期输出:
{"message":"hello docker multi-stage build","hostname":"<container-id>"}
逐步验证清单
建议你不要只看“能不能跑”,而是按下面顺序验证。
1. 验证镜像体积
docker images | grep demo-node-app
对比改造前后体积,通常会明显下降。
2. 验证运行用户
docker run --rm demo-node-app:1.0 id
你应该看到的是普通用户,而不是 root。
3. 验证构建缓存是否生效
第一次构建后,修改 src/index.js 中的返回文本,再次执行:
docker build -t demo-node-app:1.0 .
如果 Dockerfile 顺序合理,npm ci 这层应该会命中缓存,不会重新安装依赖。
4. 验证镜像内容是否足够“干净”
docker run --rm -it demo-node-app:1.0 sh
进入容器后检查:
ls -lah
你应该只看到运行所需内容,而不是整个源码仓库。
进一步优化:生产依赖与更小运行时
上面的示例为了讲清流程,直接把 node_modules 从依赖阶段复制到了运行阶段。
如果你的项目区分了开发依赖和生产依赖,可以继续优化。
优化思路
- 构建阶段安装完整依赖用于打包
- 运行阶段只安装生产依赖,或者只复制生产依赖
下面给出一种更贴近真实项目的写法。
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY src ./src
RUN npm run build
RUN npm prune --omit=dev
FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["npm", "run", "start"]
这里的重点是:
RUN npm prune --omit=dev
它会移除开发依赖,让最终运行时更轻一些。
常见坑与排查
这一部分很重要,因为多阶段构建不是“写完就万事大吉”。很多问题都出在细节上。
1. COPY . . 太早,导致缓存失效
现象
明明只是改了一行业务代码,结果 npm install 或 npm ci 每次都重新执行。
原因
你先复制了整个项目目录,任何文件变化都会让依赖安装层失效。
修正
先复制依赖清单,再安装依赖。
COPY package.json package-lock.json ./
RUN npm ci
COPY src ./src
2. Alpine 镜像不一定总是最优
现象
某些 Node、Python、Java 或带原生扩展的项目,在 Alpine 下构建失败,或者运行时出现兼容性问题。
原因
Alpine 使用 musl libc,有些依赖默认按 glibc 生态构建。
排查建议
- 看错误是否与原生模块、动态库有关
- 若项目依赖较复杂,可尝试
debian-slim系列镜像
边界条件
不是所有项目都适合盲目切 Alpine。
如果为省几十 MB,换来构建不稳定和排障困难,通常不划算。
3. 运行阶段缺少必要文件
现象
容器启动时报错:
- 找不到入口文件
- 找不到配置文件
- 找不到静态资源
原因
多阶段构建时,只复制了部分产物,遗漏了运行必需文件。
排查方法
进入容器看文件是否存在:
docker run --rm -it demo-node-app:1.0 sh
检查目录:
find /app -maxdepth 2 -type f
4. 使用非 root 用户后权限报错
现象
应用启动时报“permission denied”。
原因
切换用户后,应用尝试写入当前无权限目录,例如日志目录、临时目录、上传目录。
修正方式
- 在镜像构建阶段创建并授权目录
- 尽量把可写目录范围控制到最小
示例:
RUN mkdir -p /app/tmp && chown -R appuser:appgroup /app/tmp
5. .dockerignore 配错,导致文件没被复制
现象
本地有文件,但镜像构建时报找不到。
原因
被 .dockerignore 排除了。
排查建议
先检查 .dockerignore,尤其是:
dist.env- 配置目录
- 证书目录
我自己就遇到过一次,把 dist 忽略掉了,结果 CI 构建一切正常,运行阶段却找不到打包产物。这个坑非常隐蔽。
安全/性能最佳实践
这一部分可以直接当作团队 Dockerfile review 清单。
1. 默认使用非 root 用户
不建议生产容器默认 root 运行。
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
这是最基本、也最容易落地的安全基线。
2. 固定基础镜像版本,最好固定摘要
不要只写:
FROM node:18-alpine
更稳妥的做法是固定更具体的版本,甚至 digest。
这样可以减少“今天构建正常,明天同样代码却行为变化”的情况。
3. 只安装生产依赖
例如 Node 项目中:
- 构建阶段可以完整安装
- 运行阶段尽量只保留生产依赖
这能同时改善体积、安全面和启动效率。
4. 利用 BuildKit 缓存包管理器目录
例如 npm:
RUN --mount=type=cache,target=/root/.npm npm ci
对于 Maven、pip、apt 也有类似思路。
这在 CI 中的收益尤其明显。
5. 减少镜像层中的无效文件
例如不要在同一镜像里长期保留:
- 构建缓存
- 测试报告
- Git 历史
- 本地 IDE 文件
- 临时压缩包
6. 单进程、明确入口、可观测
容器启动命令尽量明确,避免多层脚本嵌套到自己都看不懂。
CMD ["npm", "run", "start"]
同时建议应用支持:
- 健康检查接口
- 结构化日志输出到 stdout/stderr
- 通过环境变量配置运行参数
7. 配合镜像扫描工具做基线检查
多阶段构建能减少攻击面,但不能替代漏洞治理。
建议在 CI 中引入镜像扫描,例如:
- Trivy
- Grype
- Docker Scout
一个典型流程如下:
flowchart TD
A[提交代码] --> B[Docker Build 多阶段构建]
B --> C[单元测试/集成测试]
C --> D[镜像漏洞扫描]
D --> E{是否通过基线}
E -- 是 --> F[推送镜像仓库]
E -- 否 --> G[阻断发布并修复]
8. 尽量避免在镜像中放密钥
不要把以下内容写死进镜像:
- API Key
- 数据库密码
- 云厂商访问密钥
- 私有证书
正确方式是通过:
- 环境变量
- Secret 管理系统
- 编排平台的密文注入能力
方案对比:不是所有项目都要同一种写法
中级开发者很容易陷入一个误区:
“学会了多阶段构建,就给所有项目套一个模板。”
其实应该按项目特征选策略。
| 场景 | 推荐方案 | 说明 |
|---|---|---|
| Go 静态编译服务 | 多阶段 + scratch/distroless | 最容易做到极小镜像 |
| Node Web 服务 | 多阶段 + slim/alpine | 关注依赖裁剪与原生模块兼容 |
| Java 应用 | 多阶段 + JRE 精简镜像 | 重点是分层与 JVM 参数 |
| Python 应用 | 多阶段 + venv/依赖缓存 | 重点在 wheels、系统依赖与体积控制 |
一个很实用的判断标准是:
- 如果项目有编译步骤,多阶段构建几乎是标配
- 如果项目依赖复杂且有本地扩展/动态库,要优先考虑稳定性,再追求极限瘦身
- 如果镜像已经很小,但构建慢,重点应该放在缓存设计,而不是继续换更小基础镜像
一份可复用的检查清单
上线前,我建议至少过一遍这份清单:
- 是否使用多阶段构建拆分编译与运行环境
- 是否避免过早
COPY . . - 是否有
.dockerignore - 是否只保留运行必需文件
- 是否使用非 root 用户
- 是否设置
NODE_ENV=production或等价环境变量 - 是否清理开发依赖/构建缓存
- 是否固定基础镜像版本
- 是否接入镜像漏洞扫描
- 是否避免把密钥打进镜像
总结
Docker 多阶段构建的价值,不只是“镜像变小一点”,而是同时改善三件事:
- 构建更快:缓存命中率更高,CI 更稳定
- 镜像更小:传输、拉取、启动更高效
- 安全更稳:减少工具链暴露,建立最小权限运行基线
如果你准备在团队里真正落地,我建议按这个顺序推进:
- 先加
.dockerignore - 再调整 Dockerfile 指令顺序,提高缓存命中
- 把构建与运行拆成多阶段
- 默认改为非 root 用户
- 最后接入镜像扫描与版本固定
边界条件也要记住:
瘦身不是唯一目标,稳定构建和可维护性更重要。
比如 Alpine 并非万能,distroless 也不是所有场景都方便排障。选择方案时,要看你的语言生态、团队经验和交付要求。
如果你现在手上就有一个“又大又慢”的 Docker 镜像,最值得先做的一件事,就是把现有 Dockerfile 拿出来,对照本文的实战版本改一遍。通常第一轮优化,就能看到比较明显的收益。