Docker 多阶段构建与镜像瘦身实战:从构建提速到安全上线的完整优化方案
很多团队刚上 Docker 时,最先解决的是“能跑起来”;真正到 CI/CD、镜像仓库、线上发布阶段,才会发现另一个问题更扎心:镜像太大、构建太慢、上线不稳、安全风险还高。
我自己第一次接手一个 Node.js 服务时,镜像接近 1GB,构建一次要好几分钟。更离谱的是,线上镜像里还带着编译工具链、源码和测试文件。后来做了一轮多阶段构建和镜像瘦身,镜像体积和构建耗时都降了不少,发布过程也更可控。
这篇文章不讲空泛概念,直接从一个可运行的例子入手,带你把 Docker 镜像从“能用”优化到“适合上线”。
背景与问题
在日常开发里,Docker 镜像臃肿通常来自这几个原因:
- 构建环境和运行环境混在一起
- 编译器、依赖管理工具、缓存文件都被带进最终镜像。
- 基础镜像选得过大
- 例如直接用完整版 Ubuntu、Node、Python 镜像。
- Dockerfile 层设计不合理
- 一点小变更就导致缓存失效,全量重建。
- 上下文太大
.git、测试数据、日志、node_modules全被发送给 Docker daemon。
- 以 root 运行
- 不是“不能用”,而是上线后风险更高。
- 把密钥、配置、调试工具打进镜像
- 这类问题平时不显眼,出事时很难补救。
如果你遇到以下现象,基本就该优化了:
docker build越来越慢- 镜像推送到仓库耗时长
- 容器启动慢
- 安全扫描报警一堆
- 线上镜像和本地构建不一致
前置知识与环境准备
本文示例基于一个简单的 Node.js 应用,环境如下:
- Docker 20.10+
- 推荐启用 BuildKit
- Linux / macOS / Windows + Docker Desktop 均可
先开启 BuildKit,后面的缓存优化会更明显:
export DOCKER_BUILDKIT=1
准备一个最小可运行项目结构:
mkdir docker-multistage-demo
cd docker-multistage-demo
mkdir src
示例文件
package.json
{
"name": "docker-multistage-demo",
"version": "1.0.0",
"description": "Demo for docker multi-stage build",
"main": "src/index.js",
"scripts": {
"start": "node src/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 docker multi-stage build",
hostname: process.env.HOSTNAME || "unknown"
});
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`server running at http://0.0.0.0:${port}`);
});
核心原理
什么是多阶段构建
多阶段构建的思路很直接:
- 前面阶段负责安装依赖、编译、打包
- 最后阶段只保留运行必需产物
也就是说,构建工具链留在“中间层”,不进入最终镜像。
flowchart LR
A[源码与依赖清单] --> B[构建阶段<br/>安装依赖/编译]
B --> C[产出构建结果]
C --> D[运行阶段<br/>只复制必要文件]
D --> E[最终瘦身镜像]
为什么它能同时提升速度与安全性
多阶段构建的收益通常有三层:
- 体积变小
- 最终镜像不再包含 gcc、make、npm cache、测试文件等。
- 构建更快
- 合理拆分
COPY和RUN后,可以更充分利用缓存。
- 合理拆分
- 攻击面更小
- 少装一个 shell、少一个包管理器、少一套工具链,就少一部分风险。
Docker 缓存命中原理
Docker 构建本质上是逐层执行。某一层的输入变了,后面的层大概率都会失效。
所以 Dockerfile 里一个非常关键的策略是:
- 先复制依赖描述文件
- 先安装依赖
- 最后再复制业务源码
这样如果你只改了 src/index.js,依赖层通常还能复用。
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant Cache as 层缓存
Dev->>Docker: COPY package.json
Docker->>Cache: 检查依赖层缓存
Cache-->>Docker: 命中
Dev->>Docker: COPY src/
Docker->>Cache: 检查源码层缓存
Cache-->>Docker: 未命中
Docker->>Docker: 仅重建后续层
先看一个“能跑但不优雅”的 Dockerfile
这是很多项目里常见的写法:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["npm", "start"]
它的问题有几个:
COPY . .太早,任何源码变动都会让依赖安装层失效- 基础镜像偏大
- 最终镜像包含完整源码、可能还有无关文件
- 默认 root 用户运行
- 没有利用多阶段构建
实战代码:从普通构建到多阶段瘦身
下面我们一步一步改。
第一步:加上 .dockerignore
这个文件很容易被忽略,但收益很直接。它控制哪些内容不参与构建上下文上传。
.dockerignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
coverage
dist
*.md
为什么这一步重要
如果不加 .dockerignore:
- 本地
node_modules可能被错误复制进镜像 - Git 历史会增加构建上下文
- 测试报告、日志、文档都会拖慢构建
我见过一个项目仅仅排除 .git 后,构建上下文就从几百 MB 掉到了几十 MB。
第二步:优化层缓存
先做一个不带多阶段、但缓存更友好的版本:
FROM node:18-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production
COPY src ./src
EXPOSE 3000
CMD ["node", "src/index.js"]
这里做了什么
node:18-alpine比完整版更轻- 先复制
package.json,让依赖安装层更稳定 - 使用
npm ci保证依赖安装可重复 - 只复制运行所需源码
不过,这个版本仍然没有真正做到“构建阶段”和“运行阶段”分离。
第三步:使用多阶段构建
对于 Node.js 这种场景,多阶段即使没有编译产物,也依然有价值。因为你可以在 builder 阶段完成依赖安装、测试或打包,在 runtime 阶段只留下必要结果。
推荐 Dockerfile
Dockerfile
FROM node:18-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json* ./
RUN npm ci
FROM base AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "src/index.js"]
这个版本已经比最初版本好很多:
- 把依赖安装放到单独阶段
- 运行阶段只复制需要的内容
- 使用非 root 用户运行
但还有一个细节:npm ci 默认会装开发依赖。如果你的项目里 devDependencies 很多,最终体积还是会偏大。
第四步:只保留生产依赖
更适合上线的版本如下:
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM node:18-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production
FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "src/index.js"]
如果你有测试、构建、打包步骤,可以把 deps 阶段用于测试,把 prod-deps 阶段用于最终运行依赖。
第五步:带测试/构建阶段的完整上线版本
为了更贴近真实项目,下面给一个“构建 + 测试 + 运行”的标准模板。
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM deps AS test
WORKDIR /app
COPY . .
RUN npm test --if-present
FROM deps AS build
WORKDIR /app
COPY . .
RUN npm run build --if-present
FROM node:18-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production
FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-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 ["node", "dist/index.js"]
这个模板适合以下场景:
- TypeScript 编译
- 前端静态资源打包
- Babel / Webpack / Vite 构建
- 单元测试先行,构建失败即阻断发布
构建与运行验证
构建镜像
docker build -t docker-multistage-demo:1.0 .
运行容器
docker run --rm -p 3000:3000 docker-multistage-demo:1.0
验证接口
curl http://localhost:3000
你应该能看到类似输出:
{"message":"hello docker multi-stage build","hostname":"..."}
查看镜像体积
docker images | grep docker-multistage-demo
查看镜像层历史
docker history docker-multistage-demo:1.0
这一步很实用。很多人只看最终镜像大小,却不知道究竟是哪一层最重。docker history 往往一下就能看出问题。
逐步验证清单
如果你准备在自己的项目里落地,建议按这个清单逐项验证:
- 是否存在
.dockerignore - 是否先复制依赖描述文件,再安装依赖
- 是否使用
npm ci/pip install -r/go mod download这类可重复安装方式 - 是否使用多阶段构建隔离编译环境
- 最终镜像是否只包含运行所需文件
- 容器是否使用非 root 用户
- 是否避免把密钥写入镜像
- 是否做过镜像扫描
- 是否验证过容器启动与健康检查
常见坑与排查
这一部分我尽量说得“像在现场排问题”,因为这些坑真是很常见。
1. COPY --from=build 报文件不存在
例如:
COPY --from=build /app/dist ./dist
报错:
COPY failed: stat /app/dist: no such file or directory
原因
- 构建阶段根本没生成
dist - 构建命令写错
- 工作目录不一致
排查方式
可以单独构建到某个中间阶段:
docker build --target build -t demo-build-stage .
然后进入镜像查看:
docker run --rm -it demo-build-stage sh
检查 dist 是否真的存在。
2. 改一行代码却重新安装全部依赖
常见原因
Dockerfile 写成了:
COPY . .
RUN npm ci
只要任何文件变化,npm ci 这一层都会失效。
正确做法
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
3. Alpine 镜像导致某些依赖编译失败
alpine 很轻,但它使用 musl libc,不是所有原生依赖都兼容得完美。
典型现象
- Node 原生模块安装失败
- Python / Java / 二进制依赖异常
- 运行期出现动态链接库问题
处理建议
如果你的依赖里包含复杂原生库,不要强行追求最小镜像。可以改用:
node:18-slimpython:3.11-slimdebian:bookworm-slim
边界条件很重要:更小不一定更合适。
4. 容器里用非 root 用户后,文件权限报错
例如:
EACCES: permission denied
原因
复制进来的文件归属、运行目录权限不匹配。
处理方式
可以在复制时直接指定归属,或提前创建目录并授权。
COPY --chown=appuser:appgroup src ./src
COPY --chown=appuser:appgroup package.json ./
或者:
RUN mkdir -p /app && chown -R appuser:appgroup /app
5. 明明删了文件,镜像还是很大
这是 Docker 分层机制导致的经典误区。
例如你在一层里安装了很多包,下一层再删除,它们不一定真的从镜像历史里消失。
错误示例
RUN apk add --no-cache curl gcc make
RUN apk del gcc make
虽然最终容器里可能看不到这些工具,但镜像层历史依然可能偏大。
更好的方式
- 直接用多阶段构建
- 把构建工具留在 builder 阶段
- 运行阶段完全不安装这些工具
安全/性能最佳实践
这部分是上线前最值得执行的清单。
1. 基础镜像尽量小,但不要盲目极限压缩
建议优先级:
- 先选官方镜像
- 再选
slim/alpine - 最后根据兼容性决定
经验上:
- 业务简单、依赖纯净:优先
alpine - 原生依赖较多:优先
slim
2. 固定基础镜像版本,不要只写 latest
不推荐:
FROM node:latest
推荐:
FROM node:18-alpine
更严谨一点还可以固定 digest,不过这通常在生产环境和供应链要求更高时再做。
3. 使用非 root 用户运行
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
这是低成本、高收益的安全加固动作。
4. 使用只读文件系统与资源限制
运行时可以进一步收紧权限:
docker run --read-only --tmpfs /tmp --memory=256m --cpus=1 docker-multistage-demo:1.0
适合无状态服务,但前提是应用不要依赖写本地磁盘。
5. 不要把密钥写进镜像
不要这样:
ENV ACCESS_KEY=xxxxx
ENV SECRET_KEY=yyyyy
更合理的方式:
- 运行时注入环境变量
- 使用 Docker secrets / K8s Secret / 外部密钥管理服务
6. 做镜像漏洞扫描
常见工具:
- Trivy
- Docker Scout
- Grype
例如使用 Trivy:
trivy image docker-multistage-demo:1.0
安全不是“构建一次就完事”,而是持续扫描、持续修复。
7. 利用 BuildKit 缓存提升构建速度
例如 npm/yarn/pip 这类依赖下载很适合加缓存挂载。
Node 示例:
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm npm ci
这对 CI 构建提速很明显,尤其是依赖多、构建频繁的项目。
8. 关注镜像内容而不是只盯总大小
一个 120MB 的镜像未必比 200MB 的镜像更安全。关键要看:
- 是否包含编译工具
- 是否包含 shell 和调试工具
- 是否包含源码和敏感配置
- 是否有不必要的软件包
也就是说,瘦身不是目的,最小化攻击面才是更长期的目标。
一张图看完整优化路径
flowchart TD
A[原始 Dockerfile] --> B[添加 .dockerignore]
B --> C[调整 COPY 顺序以命中缓存]
C --> D[拆分构建阶段与运行阶段]
D --> E[仅复制生产依赖与产物]
E --> F[使用非 root 用户]
F --> G[镜像扫描与上线验证]
推荐的通用模板
如果你想快速迁移现有项目,可以从这个模板开始改:
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM deps AS build
WORKDIR /app
COPY . .
RUN npm run build --if-present
FROM node:18-alpine AS prod-deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm npm ci --only=production
FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
方案取舍:什么时候不用多阶段构建
虽然我很推荐多阶段构建,但也不是任何项目都必须上来就搞得很复杂。
以下情况可以适当简化:
- 极小型脚本服务
- 没有编译步骤,依赖极少,镜像本身已很小。
- 构建产物天然很简单
- 比如 Go 静态二进制,一阶段也能跑,但多阶段依然更干净。
- 团队对 Docker 还不熟
- 先把
.dockerignore、缓存命中、非 root 这些做好,收益已经很大。
- 先把
也就是说,优化要分层推进,不一定一上来就追求“最强模板”。
总结
如果要把这篇文章压缩成几条最实用的建议,我会给你这份落地版清单:
- 先加
.dockerignore- 这是最低成本的提速手段。
- 调整 Dockerfile 顺序
- 先复制依赖描述文件,再安装依赖,最后复制源码。
- 使用多阶段构建
- 编译归编译,运行归运行,不要把工具链带到线上。
- 只保留生产依赖
- devDependencies、测试工具、缓存文件都不该进最终镜像。
- 使用非 root 用户
- 这是上线安全的基础动作。
- 做镜像扫描
- 瘦身只是第一步,安全才是上线底线。
- 根据依赖兼容性选择
alpine或slim- 别为了小体积牺牲稳定性。
最后给一个边界判断:
如果你的项目已经具备 CI/CD、镜像仓库、自动部署能力,那么多阶段构建和镜像瘦身基本不是“锦上添花”,而是必须补齐的工程化能力。它能直接影响构建效率、发布稳定性和安全质量。
如果你准备改造现有项目,建议不要一次性重写全部 Dockerfile。最稳的路径通常是:
- 先加
.dockerignore - 再优化层缓存
- 再切换到多阶段构建
- 最后补齐非 root、扫描、运行时限制
这样改,风险最小,收益也最容易量化。