Docker 多阶段构建与镜像瘦身实战:面向中级开发者的构建提速与安全优化指南
很多团队刚开始用 Docker 时,最先关注的是“能跑起来”。但项目一旦进入持续集成、频繁发布、多人协作阶段,问题就会集中爆发:
- 镜像太大,拉取慢、构建慢、发布慢
- Dockerfile 越写越长,维护困难
- 构建工具、测试文件、包管理缓存全被带进生产镜像
- 容器以 root 运行,安全风险上升
- 明明改了一行代码,却从头开始构建
这些问题并不罕见。我自己早期也踩过坑:一个 Node 服务镜像做到 1GB 以上,CI 每次构建都像“重新装一台机器”。后来真正把多阶段构建、缓存策略和安全基线整理好,构建时间和镜像体积都明显下降。
这篇文章就从实战视角带你完整做一遍:不仅讲“什么是多阶段构建”,更讲为什么这样拆、怎么验证、出了问题怎么排查。
背景与问题
先看一个典型的“能用但不优雅”的 Dockerfile。
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
它的问题很集中:
-
构建依赖和运行依赖混在一起
npm install会安装开发依赖- TypeScript、测试工具、打包工具都进了最终镜像
-
缓存利用率低
COPY . .太早执行,任意文件变更都会让npm install失去缓存
-
镜像体积膨胀
- 源码、测试文件、
.git、日志、缓存都可能被拷进去
- 源码、测试文件、
-
安全面扩大
- 默认 root 用户
- 基础镜像功能太全,攻击面更大
所以,我们的目标不只是“减小镜像”,而是同时优化:
- 构建速度
- 运行镜像大小
- 供应链安全
- Dockerfile 可维护性
前置知识与环境准备
建议你具备以下基础:
- 会写基本的 Dockerfile
- 理解镜像层和缓存
- 知道
COPY、RUN、CMD的作用 - 有 Node.js 或类似编译型 Web 项目经验
本文演示环境:
- Docker 20.10+
- 推荐启用 BuildKit
- 示例项目:Node.js + TypeScript
启用 BuildKit:
export DOCKER_BUILDKIT=1
或者临时使用:
DOCKER_BUILDKIT=1 docker build -t demo-app .
核心原理
1. 什么是多阶段构建
多阶段构建的核心思想很简单:
把“构建环境”和“运行环境”分开。
在第一阶段里,你可以安装编译工具、依赖、执行打包;
在最后阶段里,只复制运行真正需要的产物,例如:
- 编译后的二进制文件
- 前端静态资源
- 生产依赖
- 配置模板
这样最终镜像就不会包含中间垃圾。
flowchart LR
A[源码] --> B[构建阶段 builder]
B --> C[编译/打包产物]
C --> D[运行阶段 runner]
D --> E[最终生产镜像]
2. Docker 层缓存为什么会影响构建速度
Docker 会按指令逐层构建。前面的层没变化,后面的构建就可以复用缓存。
最经典的优化就是:先复制依赖声明文件,再安装依赖,最后复制业务代码。
错误顺序:
COPY . .
RUN npm install
推荐顺序:
COPY package*.json ./
RUN npm ci
COPY . .
这样只有在 package.json 或 package-lock.json 变化时,依赖层才会失效。
flowchart TD
A[COPY package.json/package-lock.json] --> B[RUN npm ci]
B --> C[COPY src]
C --> D[RUN npm run build]
A2[仅修改业务代码] --> C2[重新 COPY src]
C2 --> D2[重新 build]
B -. 依赖缓存复用 .-> D2
3. 镜像瘦身的本质
镜像瘦身不是一招鲜,而是几件事叠加:
- 选择更小、更合适的基础镜像
- 减少无关文件进入构建上下文
- 用多阶段构建去掉中间产物
- 合并无意义层
- 清理包管理缓存
- 只保留运行所需依赖
- 以非 root 用户运行
可以把它理解成一句话:
让最终镜像里只留下“线上运行必须存在的东西”。
实战代码(可运行)
下面我用一个 Node + TypeScript 服务做完整演示。
项目结构
demo-app/
├── src/
│ └── server.ts
├── package.json
├── package-lock.json
├── tsconfig.json
├── .dockerignore
└── Dockerfile
示例代码
src/server.ts
import express from "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 is running at http://localhost:${port}`);
});
package.json
{
"name": "demo-app",
"version": "1.0.0",
"description": "docker multi-stage build demo",
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^4.19.2"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.7.4",
"typescript": "^5.6.2"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true
}
}
第一步:先看一个“不够好”的版本
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
这个版本能跑,但不适合生产。
第二步:改造成多阶段构建
推荐版 Dockerfile
# syntax=docker/dockerfile:1
FROM node:18-bookworm-slim AS base
WORKDIR /app
FROM base AS deps
COPY package*.json ./
RUN npm ci
FROM deps AS builder
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:18-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
RUN useradd -r -s /usr/sbin/nologin appuser \
&& chown -R appuser:appuser /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个版本做了几件关键事:
deps阶段安装完整依赖,用于构建builder阶段只负责编译runner阶段只安装生产依赖,并复制dist- 使用 slim 镜像减少基础体积
- 使用非 root 用户运行
第三步:使用 .dockerignore 减少构建上下文
.dockerignore
node_modules
dist
.git
.gitignore
Dockerfile
npm-debug.log
coverage
*.md
.env
这个文件非常重要,很多人反而忽略了。
如果没有 .dockerignore:
- 本地
node_modules可能被复制进去 - Git 历史会扩大上下文
- 日志、测试报告、缓存文件全会参与构建
这不仅使镜像变大,也会拖慢 docker build 上传上下文的速度。
第四步:构建并运行
构建镜像
docker build -t demo-app:multi-stage .
运行容器
docker run --rm -p 3000:3000 demo-app:multi-stage
验证接口
curl http://localhost:3000
预期输出:
{"message":"hello docker multi-stage build","time":"2024-01-01T00:00:00.000Z"}
逐步验证清单
建议你不要一次性“盲改”,而是按下面顺序验证:
1. 验证镜像体积
docker images | grep demo-app
对比改造前后的体积变化。
2. 验证运行用户
docker run --rm demo-app:multi-stage id
你应该看到不是 root。
3. 验证最终镜像内容是否干净
docker run --rm -it demo-app:multi-stage sh
进入后检查:
ls
确认只保留了必要文件,例如:
distpackage.jsonpackage-lock.jsonnode_modules
4. 验证依赖缓存是否生效
修改 src/server.ts,重新构建:
docker build -t demo-app:multi-stage .
观察日志,npm ci 那层应该大概率命中缓存。
多阶段构建的执行过程图
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant Deps as deps 阶段
participant Builder as builder 阶段
participant Runner as runner 阶段
Dev->>Docker: docker build
Docker->>Deps: 复制 package*.json
Deps->>Deps: npm ci
Docker->>Builder: 复制源码与 tsconfig
Builder->>Builder: npm run build
Docker->>Runner: 安装生产依赖
Builder-->>Runner: COPY dist
Runner-->>Dev: 输出最终镜像
常见坑与排查
这一部分很关键。多阶段构建本身不复杂,复杂的是“你以为已经瘦了,但结果没瘦下来”。
1. COPY . . 放太早,缓存全废
现象
每次修改一点点代码,npm ci 都重新执行。
原因
你先复制了整个项目,导致源码任意变化都会影响依赖层缓存。
解决
把依赖文件和源码分开复制:
COPY package*.json ./
RUN npm ci
COPY . .
2. 最终镜像里仍然有开发依赖
现象
镜像还是很大,甚至能看到 TypeScript、测试框架。
原因
你在最终阶段直接复制了整个 /app,把 builder 里的 node_modules 一起带过来了。
错误写法
COPY --from=builder /app /app
正确思路
只复制必要产物:
COPY --from=builder /app/dist ./dist
然后在运行阶段安装生产依赖:
RUN npm ci --omit=dev
3. Alpine 不一定总是更合适
很多文章会直接建议“用 alpine 就好了”,但这句话要加条件。
优点
- 体积更小
风险
- 基于 musl libc,某些原生模块兼容性可能有坑
- 构建依赖复杂时,排障成本会上升
如果你的项目依赖原生扩展,或者团队对 Alpine 不熟,我更建议从 debian slim 起步,比如:
FROM node:18-bookworm-slim
不是最小,但通常更稳。
4. 容器里找不到文件
现象
报错类似:
Error: Cannot find module '/app/dist/server.js'
排查顺序
- 本地
npm run build是否成功 - Dockerfile 中
COPY src ./src、COPY tsconfig.json ./是否正确 dist输出目录是否与CMD一致COPY --from=builder /app/dist ./dist路径是否正确
我自己的经验是:80% 是路径写错,20% 是构建根本没成功。
5. .dockerignore 写错导致文件缺失
现象
构建时报找不到源码或配置文件。
原因
你把必要文件排除了,比如:
src
*.json
解决
检查构建上下文中到底传了什么,必要时精简排除规则。
6. 非 root 用户导致权限问题
现象
应用启动时报权限不足,比如不能写日志、不能创建临时目录。
解决思路
- 提前创建目录并授权
- 不要把运行时写入路径放在系统目录
- 尽量把应用设计成无状态,日志输出到 stdout/stderr
例如:
RUN mkdir -p /app/tmp \
&& chown -R appuser:appuser /app
安全/性能最佳实践
这部分给你的是“可落地”的建议,不是口号。
1. 优先选择可信且精简的基础镜像
推荐原则:
- 官方镜像优先
- 固定大版本,必要时固定更具体的 tag
- 能用 slim 就别用 full
- 不盲目追求最小,先保证兼容性
示例:
FROM node:18-bookworm-slim
2. 运行时不要带构建工具链
如果最终镜像里还保留:
- gcc
- make
- python
- git
- TypeScript
- 测试框架
那就说明多阶段构建没真正做好。
最终镜像只应该包含:
- 运行时解释器或二进制
- 生产依赖
- 编译产物
3. 使用非 root 用户运行
这是最基础也最容易落地的容器安全实践之一。
RUN useradd -r -s /usr/sbin/nologin appuser \
&& chown -R appuser:appuser /app
USER appuser
边界条件也要说清楚:
- 如果应用需要绑定 1024 以下端口,可能涉及额外权限
- 如果需要写挂载卷,要确保宿主机目录权限匹配
4. 减少无效层与缓存残留
例如:
RUN npm ci --omit=dev && npm cache clean --force
如果你使用 apt 安装工具,也要及时清理:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
5. 尽量固定依赖,保证构建可重复
生产构建最怕“今天能过,明天挂掉”。
建议:
- 提交锁文件,如
package-lock.json - 使用
npm ci而不是npm install - 在 CI 中统一 Docker 构建方式
6. 配合扫描工具做漏洞检查
镜像瘦身不等于安全,但通常能减少漏洞暴露面。
可以引入工具做镜像扫描,例如:
docker scan demo-app:multi-stage
或者使用团队内部的安全平台、Trivy、Grype 等工具。
7. 结合 BuildKit 提升构建体验
对于频繁构建的项目,BuildKit 能提供更好的缓存能力和输出体验。
虽然本文没有展开缓存挂载高级玩法,但如果你的 CI 构建很重,后面可以继续研究:
RUN --mount=type=cache- registry cache
- inline cache
这些对大型项目很有帮助。
多阶段构建优化决策图
flowchart TD
A[开始优化 Dockerfile] --> B{项目需要编译/打包吗?}
B -- 是 --> C[使用多阶段构建]
B -- 否 --> D[直接精简运行镜像]
C --> E[拆分依赖安装与源码复制]
E --> F[仅复制构建产物到最终镜像]
F --> G[安装生产依赖]
G --> H[切换非 root 用户]
H --> I[增加 .dockerignore]
I --> J[扫描漏洞并验证体积]
D --> H
一个更进一步的思路:前端与后端项目的差异
虽然本文用的是 Node 服务端示例,但多阶段构建在不同项目里的思路略有不同。
前端项目
典型流程:
- builder 阶段:
npm ci && npm run build - runner 阶段:使用
nginx:alpine或静态文件服务器 - 最终只复制
dist/静态资源
Go 项目
典型流程:
- builder 阶段:编译成单个二进制
- runner 阶段:用
scratch或 distroless - 最终镜像可以非常小
Java 项目
典型流程:
- builder 阶段:Maven/Gradle 打包
- runner 阶段:只保留 JRE 或更轻量基础镜像
- 注意分层 jar 与依赖缓存
也就是说:
多阶段构建的方法论是通用的,但“复制什么到最终镜像”要看语言生态。
总结
如果你只记住一件事,我希望是这句:
多阶段构建不是为了“写得高级”,而是为了让生产镜像只保留真正需要的内容。
这篇文章我们解决了几个核心问题:
- 用多阶段构建拆分构建环境与运行环境
- 通过合理的
COPY顺序提升缓存命中率 - 用
.dockerignore缩小构建上下文 - 通过 slim 基础镜像与生产依赖安装减少体积
- 通过非 root 用户运行提升安全性
- 给出常见坑和排查路径,便于你在项目里落地
最后给你一份可执行建议,适合直接带回项目实践:
-
先改 Dockerfile 顺序
- 先复制依赖声明,再安装依赖,最后复制源码
-
把构建和运行拆成两个阶段以上
- builder 负责产物,runner 负责运行
-
补上
.dockerignore- 这是最容易漏、但收益很高的一步
-
最终镜像只保留必要文件
- 不要整目录复制 builder 的工作区
-
默认使用非 root 用户
- 安全基线从这里开始
-
构建后做三项验证
- 镜像大小
- 运行用户
- 文件内容是否最小化
如果你的项目当前镜像已经很大,不必一口气重构到底。我的建议是:先做“可观测的最小改造”,例如先引入多阶段构建和 .dockerignore,确认收益后再继续优化基础镜像、缓存策略和漏洞扫描。这样最稳,也最容易在团队里推广。