Docker 镜像瘦身实战:从多阶段构建到层缓存优化的中级指南
很多团队在用 Docker 一段时间后,都会遇到一个很现实的问题:镜像越来越大,构建越来越慢,CI 越跑越久,发布也越来越“肉”。
我自己第一次认真做镜像瘦身,是因为一个 Node.js 服务镜像做到了 1GB 以上。构建慢、推送慢、拉取慢,线上回滚也慢。后来回头看,问题并不神秘:把不该打进镜像的东西都打进去了,把本可以缓存的步骤放错了位置,还把构建工具链也一起带到了生产环境。
这篇文章不讲太“玄”的理论,而是按实战思路,带你把一个常见应用镜像一步步优化下来,重点放在两个方向:
- 多阶段构建:只把运行时真正需要的内容放进最终镜像
- 层缓存优化:让经常不变的步骤尽量命中缓存,减少重复构建
适合已经会写基础 Dockerfile、但希望进一步把镜像做小、做快的中级读者。
背景与问题
先看几个典型症状:
- 基础业务服务镜像动不动几百 MB,甚至上 GB
- 代码改一行,
npm install/pip install/go mod download又来一遍 - CI/CD 构建时间持续膨胀
- 镜像里混进源码、测试文件、构建缓存、包管理器缓存
- 生产镜像里居然还带着编译器、调试工具、Git
这些问题表面上看是“镜像大”,但本质一般分成两类:
1. 内容装太多
比如:
- 使用
node:latest、python:latest这种偏大的基础镜像 - 把
.git、测试数据、日志、构建产物、依赖缓存都复制进去了 - 构建依赖和运行依赖没分开
- 临时文件在某一层创建了,即使后面删除,那层体积还在
2. 构建顺序不合理
Docker 构建依赖层缓存。如果 COPY . . 放得太早,那么任何代码改动都会让后续层全部失效。最常见的后果就是:
- 依赖重装
- 编译重跑
- 镜像重打包
- CI 时间暴涨
前置知识与环境准备
本文示例以一个简单的 Node.js Web 服务为例,但方法也适用于 Go、Java、Python 等项目。
环境准备
- Docker 20.x 及以上
- 推荐启用 BuildKit
export DOCKER_BUILDKIT=1
验证 Docker 版本:
docker version
示例目录结构:
demo-app/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
├── src/
│ └── index.js
└── dist/
核心原理
在动手之前,先把几个关键原理捋顺。理解了这部分,后面很多优化动作就不是“背答案”,而是自然推出来的。
原理 1:Docker 镜像是分层的
每条 RUN、COPY、ADD 等指令,都会生成一个新层。镜像不是一个单文件,而是一组叠加的只读层。
flowchart TD
A[基础镜像层] --> B[安装系统依赖]
B --> C[安装应用依赖]
C --> D[复制源码]
D --> E[构建产物]
E --> F[容器运行时视图]
这里最容易误解的一点是:
你在后续层删除前面层里的文件,并不会真正减少前面层的体积。
比如:
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
虽然第二层删除了缓存文件,但如果第一层已经把大量缓存写进去了,镜像总体仍然会受影响。更好的方式是把相关操作合并到同一个 RUN 中。
原理 2:缓存命中取决于指令和上下文是否变化
Docker 会从上到下构建。某一层缓存失效后,后续层通常都要重建。
例如:
COPY . .
RUN npm install
只要项目里任何文件变了,COPY . . 这一层就变,后面的 npm install 也会重跑。
但如果改成:
COPY package*.json ./
RUN npm ci
COPY . .
那只要依赖声明文件没变,npm ci 就可以复用缓存。
原理 3:构建环境和运行环境往往不是一回事
很多应用构建时需要:
- 编译器
- 打包工具
- 头文件
- Git
- 测试工具
但运行时只需要:
- 二进制文件
- 构建后的静态资源
- 必要的运行时依赖
这就是多阶段构建的价值:前面阶段负责“生产”,最后阶段只负责“交付”。
flowchart LR
A[builder 阶段<br/>安装编译工具/下载依赖/构建] --> B[生成 dist 或二进制]
B --> C[runtime 阶段<br/>仅复制运行所需文件]
C --> D[更小的生产镜像]
实战代码(可运行)
下面我们从一个“能跑但不优”的 Dockerfile 开始,逐步优化。
示例应用
先准备一个最小 Node.js 服务。
package.json
{
"name": "demo-app",
"version": "1.0.0",
"description": "Docker image optimization demo",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"build": "mkdir -p dist && cp -r src/* dist/"
},
"dependencies": {
"express": "^4.18.2"
}
}
src/index.js
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('hello docker slim image');
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`server listening on ${port}`);
});
第一步:先看一个“常见但不优”的 Dockerfile
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
这个写法的问题很典型:
node:18默认镜像不算小COPY . .太早,导致缓存命中差npm install会安装更多不必要内容,且依赖版本可能漂移- 构建工具和源码都保留在生产镜像中
- 没有
.dockerignore
构建:
docker build -t demo-app:fat .
运行:
docker run --rm -p 3000:3000 demo-app:fat
第二步:先加 .dockerignore
这一步经常被低估,但收益很大。它影响的是构建上下文大小。上下文越大,发送给 Docker daemon 的内容越多,COPY 越容易无谓失效。
.dockerignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
dist
coverage
*.md
你可以先观察构建日志里的这一行:
Sending build context to Docker daemon
如果没有 .dockerignore,这个上下文大小可能非常夸张。很多项目还没开始构建,时间已经花在传输无关文件上了。
第三步:优化层顺序,先命中依赖缓存
改成这样:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY src ./src
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
这里做了两件事:
- 基础镜像换成
node:18-alpine - 先复制依赖声明,再安装依赖,最后复制源码
为什么有效?
- 代码改动频繁,依赖文件改动相对少
- 让
npm ci尽量待在变化较少的层上,就能复用缓存
不过它仍然有两个不足:
- 构建依赖和运行依赖还混在一起
- 最终镜像里仍有源码和整个 Node 运行环境的“构建痕迹”
第四步:使用多阶段构建
下面是更实战的版本。
Dockerfile(推荐版)
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 runtime
WORKDIR /app
ENV NODE_ENV=production
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阶段负责安装依赖、复制源码、执行构建runtime阶段只保留运行所需依赖和构建产物
构建:
docker build -t demo-app:slim .
运行:
docker run --rm -p 3000:3000 demo-app:slim
测试:
curl http://localhost:3000
输出:
hello docker slim image
第五步:进一步优化缓存与构建速度
如果你已经启用 BuildKit,可以使用缓存挂载优化依赖下载速度。
Dockerfile(BuildKit 缓存优化版)
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY src ./src
RUN npm run build
FROM node:18-alpine AS runtime
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
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
这里的 --mount=type=cache 不会把缓存直接烘焙进最终镜像层,而是用于加速重复构建。尤其在 CI 或本地反复构建时,体感会很明显。
构建流程可视化
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant B as builder 阶段
participant R as runtime 阶段
Dev->>Docker: docker build
Docker->>B: 复制 package*.json
B->>B: npm ci
Docker->>B: 复制 src
B->>B: npm run build
Docker->>R: 复制 package*.json
R->>R: npm ci --omit=dev
B->>R: 复制 dist
R-->>Dev: 输出瘦身后的镜像
第六步:验证镜像是否真的变小、变快
优化不是“看起来像优化”,最好做验证。
查看镜像体积
docker images | grep demo-app
查看层组成
docker history demo-app:slim
你会更直观看到哪些步骤生成了较大的层。
对比构建缓存是否命中
第一次构建:
docker build -t demo-app:slim .
只修改 src/index.js 后再次构建:
docker build -t demo-app:slim .
理想情况:
COPY package*.json命中缓存npm ci命中缓存- 只有复制源码和构建步骤重跑
逐步验证清单
你可以按下面这份清单来检查自己的 Dockerfile 是否已经走在正确方向上:
- 是否使用了更合适的基础镜像,而不是默认
latest - 是否配置了
.dockerignore - 是否把依赖安装步骤放在源码复制之前
- 是否使用了
npm ci而不是npm install - 是否通过多阶段构建隔离了构建环境和运行环境
- 最终镜像是否只保留运行所需文件
- 是否清理了包管理器缓存
- 是否使用非 root 用户运行
- 是否通过
docker history看过镜像层 - 是否对构建时间和镜像大小做过前后对比
常见坑与排查
这部分我踩过不少坑,很多问题其实不是 Docker “有毛病”,而是镜像构建逻辑没设计好。
坑 1:删除文件了,为什么镜像还是大?
典型写法:
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
问题在于这些操作分成了多层。删除发生在后面的层,前面层已经把体积留住了。
更好的写法:
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*
坑 2:COPY . . 一改代码就全量重建
这通常是缓存失效的根因。排查方式:
- 看 Dockerfile 中
COPY . .是否过早出现 - 看依赖安装步骤是不是放在它后面
- 看是否缺少
.dockerignore
坑 3:Alpine 镜像更小,但应用反而构建失败
alpine 基于 musl libc,一些原生模块或二进制依赖在编译、运行时可能会有兼容问题。比如 Node 的某些原生扩展、Python 一些带 C 扩展的包。
排查思路:
- 查看构建日志中是否出现编译错误
- 检查依赖是否需要 glibc
- 尝试切换到
debian-slim变体验证
边界条件很重要:不是所有项目都适合 Alpine。如果为了省几十 MB,换来调试成本暴涨,未必划算。
坑 4:多阶段构建后,程序启动报文件找不到
常见原因:
- 只复制了构建产物,没复制运行必须的配置文件
- 工作目录不一致
CMD指向旧路径- 构建产物目录和实际输出目录不一致
排查命令:
docker run --rm -it demo-app:slim sh
进入容器后直接看文件:
ls -R /app
坑 5:依赖缓存没命中
检查这几项:
package-lock.json是否频繁变化- 是否在依赖安装前执行了
COPY . . - CI 是否每次都在全新环境中构建
- 是否启用了 BuildKit 缓存挂载
坑 6:镜像小了,但构建仍慢
这说明你优化了“产物体积”,却没真正优化“构建路径”。常见原因:
- 网络下载依赖仍是主要瓶颈
- 编译步骤本身耗时长
- 没有远程缓存策略
- 基础镜像层每次都在重新拉取
这时应分开看两个指标:
- 镜像大小
- 构建时长
它们相关,但不是一回事。
安全/性能最佳实践
镜像瘦身不只是为了“看上去专业”,它也直接影响安全和交付效率。
1. 固定基础镜像版本,不要滥用 latest
不推荐:
FROM node:latest
推荐:
FROM node:18-alpine
更进一步,可以固定到更具体的版本标签。这样可重复性更好,也更利于排查问题。
2. 使用最小满足需求的基础镜像
选择顺序一般可以这样考虑:
- 能用 distroless:优先考虑
- 不行再看 alpine
- Alpine 不兼容时用 debian-slim
但记住:兼容性优先于极致瘦身。
3. 不要把构建工具带进生产镜像
例如:
- gcc
- make
- git
- curl
- 调试工具链
这些东西既占空间,也扩大攻击面。
4. 使用非 root 用户运行
USER node
或者自己创建运行用户。即使是内部服务,我也建议养成这个习惯。
5. 减少不必要的包和缓存
例如 Node 项目:
RUN npm ci --omit=dev && npm cache clean --force
系统包安装时:
RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates && \
rm -rf /var/lib/apt/lists/*
6. 关注镜像中的敏感信息
不要把这些东西打进镜像:
- 私钥
.env生产密钥- 云平台凭证
- 私有仓库 token
如果构建时需要使用密钥,优先考虑 BuildKit secret,而不是 ARG 或直接 COPY。
7. 把“变动少”的内容放前面
一条经验公式:
- 基础环境
- 系统依赖
- 应用依赖声明
- 安装依赖
- 源码
- 构建
- 启动命令
按这个顺序,缓存利用率通常不会太差。
一张状态图:从“臃肿”到“可发布”
stateDiagram-v2
[*] --> 初始镜像
初始镜像 --> 加入dockerignore: 排除无关文件
加入dockerignore --> 调整层顺序: 优化缓存命中
调整层顺序 --> 多阶段构建: 分离构建与运行
多阶段构建 --> 清理缓存与临时文件
清理缓存与临时文件 --> 非root运行
非root运行 --> 可发布镜像
可发布镜像 --> [*]
一个更通用的 Dockerfile 模板
如果你想把思路迁移到自己的 Node 项目,可以从这个模板出发:
# 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 . .
RUN npm run build
FROM node:18-alpine AS runtime
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
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
适用前提:
- 项目有明确的构建产物目录,如
dist/ - 运行时不依赖源码中的其他文件
- 依赖管理使用 lockfile
如果你的应用在运行时还需要模板、配置、静态资源,那就把这些目录明确复制进去,不要默认依赖 COPY . . 一把梭。
总结
做 Docker 镜像瘦身,最有效的不是“疯狂删文件”,而是抓住两个核心点:
- 多阶段构建:让最终镜像只保留运行必需内容
- 层缓存优化:让依赖安装等重步骤尽量稳定命中缓存
你可以把本文的方法落到一个简单执行方案里:
- 先加
.dockerignore - 再调整 Dockerfile 顺序:先复制依赖文件,再安装依赖,再复制源码
- 引入多阶段构建,拆分 builder 和 runtime
- 清理缓存、减少系统包、使用非 root 用户
- 用
docker history和构建日志验证,而不是凭感觉优化
最后给一个很实际的建议:不要盲目追求“最小镜像”,而要追求“足够小、足够稳、足够快”。
如果 Alpine 让你兼容性问题不断,那就退回 slim;如果多阶段让构建逻辑过于复杂,也可以先在关键服务上落地。优化是工程权衡,不是体重竞赛。
如果你现在手里正好有一个构建慢、镜像大的服务,最值得先做的动作只有一个:
打开 Dockerfile,看看你的 COPY . . 是不是放早了。