Docker 镜像体积优化实战:多阶段构建、层缓存与构建提速方案
做容器化项目时,很多团队一开始只关注“能跑起来”,等 CI 变慢、镜像仓库膨胀、部署时间拉长,才发现镜像体积和构建速度已经成了隐性成本。
我自己刚开始用 Docker 时,也写过那种“一个 Dockerfile 装天下”的版本:构建工具、源码、测试依赖、临时文件全都打进镜像里,最后一个简单服务能做出 1GB+ 的镜像。后来回头看,问题并不复杂,核心就三件事:
- 把不该进运行镜像的东西剥离掉
- 让 Docker 尽可能复用已有层缓存
- 减少无效拷贝和重复下载
这篇文章我会从“为什么慢、为什么大”讲起,然后带你一步步把一个常见 Node.js 服务镜像优化到更轻、更快、更适合生产环境。
背景与问题
先看一个很典型的 Dockerfile:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
它的问题几乎是“新手全家桶”:
COPY . .太早,任何源码变动都会让依赖层缓存失效- 构建依赖和运行依赖混在一起
- 源码、测试文件、Git 元数据可能全被打进镜像
- 没有使用多阶段构建,最终镜像携带完整编译环境
- 依赖安装策略不稳定,容易导致 CI 时间波动
实际影响通常有这些:
- 镜像体积大:拉取慢、分发慢、磁盘占用高
- 构建时间长:CI/CD pipeline 更慢
- 缓存命中率低:改一行代码就重新安装全部依赖
- 安全面扩大:运行镜像里包含不必要工具链和包管理器
如果你的团队已经遇到下面任意一个现象,就值得系统优化:
docker build一次要几分钟甚至十几分钟- 镜像大小几百 MB 到几个 GB
- 同样代码在 CI 上频繁重新下载依赖
- 生产镜像里居然还能执行
gcc、make之类命令
前置知识与环境准备
本文示例基于以下环境:
- Docker 20.10+
- 建议开启 BuildKit
- 一个简单的 Node.js/TypeScript 服务
先开启 BuildKit(本地 shell 临时生效):
export DOCKER_BUILDKIT=1
也可以在构建时显式指定:
DOCKER_BUILDKIT=1 docker build -t demo-app .
示例项目结构如下:
demo-app/
├── src/
│ └── server.ts
├── package.json
├── package-lock.json
├── tsconfig.json
├── .dockerignore
└── Dockerfile
示例 package.json:
{
"name": "demo-app",
"version": "1.0.0",
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.1",
"typescript": "^5.7.2"
}
}
示例 src/server.ts:
import express from "express";
const app = express();
const port = process.env.PORT || 3000;
app.get("/", (_, res) => {
res.json({ ok: true, message: "hello docker optimize" });
});
app.listen(port, () => {
console.log(`server listening on ${port}`);
});
核心原理
镜像优化不是玄学,理解下面三个原理就够了。
1. Docker 镜像是分层的
Dockerfile 中大多数指令都会形成镜像层。构建时,Docker 会尝试复用之前已有的层。
例如:
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
如果你只改了 src/server.ts,理论上:
COPY package*.json ./不变RUN npm ci这一层可复用缓存- 只有后面的源码复制和构建重新执行
但如果你上来就是 COPY . .,那么只要项目里任意文件变化,后面所有层都可能失效。
2. 多阶段构建的本质是“构建环境”和“运行环境”分离
构建阶段需要:
- 编译器
- devDependencies
- 构建脚本
- 源码
运行阶段通常只需要:
- 编译产物
- 生产依赖
- 最小运行时
把这两者拆开,最终镜像自然会瘦很多。
3. 缓存优化的核心是“稳定输入放前面,频繁变化放后面”
一般规律:
- 依赖清单变化频率低,应该尽量前置
- 源码变化频率高,应该后置
- 大体积无关文件要通过
.dockerignore排除
先看整体优化思路
下面这张图可以先建立全局认知:
flowchart TD
A[源码目录] --> B[构建阶段 builder]
B --> C[安装依赖 npm ci]
C --> D[编译源码 npm run build]
D --> E[产出 dist]
E --> F[运行阶段 runner]
F --> G[仅复制 dist 与生产依赖]
G --> H[轻量运行镜像]
再看缓存命中的关键路径:
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant Cache as 层缓存
Dev->>Docker: 发送 package.json / lock 文件
Docker->>Cache: 查询依赖安装层
Cache-->>Docker: 命中则复用 npm ci 结果
Dev->>Docker: 发送 src 源码
Docker->>Docker: 仅重跑构建层
Docker-->>Dev: 输出新镜像
实战代码(可运行)
下面我们分三步优化。
第一步:加上 .dockerignore
这是最容易被忽略、但收益很直接的一步。
node_modules
dist
.git
.gitignore
Dockerfile
npm-debug.log
README.md
coverage
*.local
为什么重要?
因为 docker build 时,Docker 会先把构建上下文打包发送给 daemon。上下文越大,构建越慢。而且一旦把无关文件带进去,还会影响缓存。
我见过有人把本地 node_modules 和 .git 一起发进去,构建上下文几百 MB 起步,CI 直接拖垮。
第二步:把 Dockerfile 改造成“缓存友好”版本
先给一个比原版更合理的单阶段版本:
FROM node:18-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个版本已经比最开始好很多:
- 依赖文件单独复制,便于缓存
- 源码后复制,减少
npm ci失效概率 - 基础镜像从
node:18换成node:18-slim
但它仍然有问题:
- devDependencies 还在最终镜像里
- TypeScript 编译环境也留在运行镜像
- 不够“生产化”
所以我们继续。
第三步:使用多阶段构建
下面是更适合生产环境的版本。
# syntax=docker/dockerfile:1.6
FROM node:18-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:18-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:18-slim AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:18-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个 Dockerfile 做了四件事:
deps阶段安装完整依赖,供构建使用builder阶段只负责编译prod-deps阶段只安装生产依赖runner阶段只保留运行所需文件
这样最终镜像中没有:
- TypeScript 源码
- devDependencies
- 编译中间产物
- 不必要工具链
构建与运行
docker build -t demo-app:optimized .
docker run --rm -p 3000:3000 demo-app:optimized
验证:
curl http://localhost:3000
输出示例:
{"ok":true,"message":"hello docker optimize"}
进一步提速:BuildKit 缓存挂载
如果你在 CI 中频繁构建,单靠层缓存还不够。因为有些时候层虽然失效了,但包下载缓存仍然可以复用。
比如 npm,可以这样写:
# syntax=docker/dockerfile:1.6
FROM node:18-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
作用是:
- 即使
npm ci这一层因为 lock 文件变化需要重跑 - Docker 仍可复用
/root/.npm下载缓存 - 大量减少重复下载时间
如果是 apt 安装,也可以使用类似方式:
RUN --mount=type=cache,target=/var/cache/apt \
apt-get update && apt-get install -y curl
不过注意:缓存挂载依赖 BuildKit,不是所有旧环境都默认启用。
优化前后对比思路
不同项目结果不一样,但通常能看到类似收益:
| 优化项 | 优化前 | 优化后 |
|---|---|---|
| 镜像体积 | 700MB+ | 150MB~250MB |
| 首次构建时间 | 较慢 | 略有改善 |
| 增量构建时间 | 很慢 | 明显缩短 |
| CI 依赖下载 | 经常重复 | 命中缓存后更快 |
| 运行镜像内容 | 构建环境全带上 | 仅保留运行所需 |
如果你想自己量化,可以执行:
docker images | grep demo-app
docker history demo-app:optimized
查看每层大小:
docker history --no-trunc demo-app:optimized
常见坑与排查
这部分很关键,因为很多人“照抄了 Dockerfile,结果还是慢”。
坑 1:.dockerignore 没配好
现象:
- 构建上下文很大
- 明明只改了代码,缓存还是频繁失效
排查:
docker build --progress=plain -t demo-app .
注意日志里 transferring context 的大小。如果几十 MB、几百 MB,就要怀疑上下文过大。
常见漏项:
node_modules.gitdist- 测试报告
- 本地 IDE 文件
坑 2:COPY . . 放太早
现象:
- 改一个注释,也会重新执行
npm ci - 增量构建几乎没有加速
错误写法:
COPY . .
RUN npm ci
正确思路:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
坑 3:没有 lock 文件,依赖层不稳定
如果没有 package-lock.json,你每次构建安装出来的依赖版本可能不同,缓存也更难稳定命中。
建议:
npm ci
而不是:
npm install
原因:
npm ci更适合 CI/CD- 安装结果更可预测
- 更容易复现问题
坑 4:Alpine 不是所有场景都更好
很多文章喜欢一句话:换 Alpine,镜像立刻变小。这话不算错,但不完整。
Alpine 的问题在于:
- 使用
musl,某些 Node 原生模块兼容性不如 Debian/Ubuntu 系 - 编译依赖复杂时,排错成本更高
- 某些包安装并不一定更快
如果你的服务依赖原生扩展,我更建议优先试:
node:18-slimpython:3.x-slimopenjdk:*-slim
也就是说,先追求稳定,再追求极限瘦身。
坑 5:多阶段构建后,运行时报“文件不存在”
常见原因:
- 编译产物目录写错,比如以为是
build/,实际是dist/ - 没复制
package.json - 运行命令路径不一致
排查方式:
先进入镜像检查:
docker run --rm -it demo-app:optimized sh
然后查看目录:
ls -R /app
如果是 dist 没生成,多半是 builder 阶段构建失败或者 TS 配置有问题。
坑 6:缓存没有命中,其实是基础镜像变了
如果你写的是:
FROM node:latest
那么某一天官方镜像更新后,缓存链条可能整体变化。
建议固定版本:
FROM node:18-slim
进一步更稳一点,可以固定 digest,不过维护成本更高。
安全/性能最佳实践
镜像优化不能只看“更小”,还要看生产可用性。
1. 使用非 root 用户运行
更安全的写法:
FROM node:18-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
RUN useradd -r -s /usr/sbin/nologin appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
这样即使应用被利用,攻击面也相对更小。
2. 减少镜像中不必要的软件包
如果你安装系统依赖,尽量避免“顺手装一堆”:
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
重点:
- 用
--no-install-recommends - 清理 apt 索引
- 不要把调试工具长期留在生产镜像中
3. 合理排序 Dockerfile 指令
一个经验法则:
- 基础镜像
- 不常变化的系统依赖
- 包描述文件
- 依赖安装
- 源码复制
- 构建
- 运行配置
这样层缓存通常最友好。
4. 结合 CI 做远程缓存
如果你用 GitHub Actions、GitLab CI 或 Buildx,可以进一步启用远程缓存。思路如下:
flowchart LR
A[开发者提交代码] --> B[CI 触发 docker buildx]
B --> C[拉取远程缓存]
C --> D[执行增量构建]
D --> E[推送镜像]
D --> F[回写最新缓存]
这样即使 CI Runner 是临时机器,也能尽可能复用之前构建结果。
5. 定期扫描镜像漏洞
镜像变小不等于一定更安全。建议配合漏洞扫描工具:
- Trivy
- Grype
- Docker Scout
例如:
trivy image demo-app:optimized
如果你的基础镜像长期不更新,小镜像照样可能有高危漏洞。
6. 不要为了极致缓存牺牲可维护性
有些 Dockerfile 会把步骤拆得过碎、写得极度技巧化,团队新人根本看不懂。我的建议是:
- 先做到结构清晰
- 再做缓存细节优化
- 只保留真正有收益的技巧
毕竟构建文件也是代码,后续要维护的。
逐步验证清单
如果你想按教程自己动手,可以按下面清单检查。
检查 1:构建上下文是否变小
docker build --progress=plain -t demo-app .
看日志中的 context 大小是否明显下降。
检查 2:改业务代码后,依赖安装是否命中缓存
修改 src/server.ts 一行内容,再构建:
docker build --progress=plain -t demo-app .
观察 npm ci 对应层是否显示缓存命中。
检查 3:最终镜像是否只包含运行所需内容
docker run --rm -it demo-app:optimized sh
检查是否还存在源码、测试目录、TypeScript 配置等无关内容。
检查 4:镜像大小是否明显下降
docker images
对比优化前后的镜像体积。
检查 5:服务是否正常启动
docker run --rm -p 3000:3000 demo-app:optimized
curl http://localhost:3000
一个更完整的生产示例
如果你希望直接拿去做模板,可以参考下面这个版本:
# syntax=docker/dockerfile:1.6
FROM node:18-slim AS base
WORKDIR /app
FROM base AS deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json tsconfig.json ./
COPY src ./src
RUN npm run build
FROM base AS prod-deps
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
FROM base AS runner
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json ./
RUN useradd -r -s /usr/sbin/nologin appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
配套 .dockerignore:
node_modules
dist
.git
coverage
Dockerfile
README.md
*.log
这个版本兼顾了:
- 多阶段构建
- 依赖缓存
- 生产依赖裁剪
- 非 root 运行
- 更好的可读性
总结
Docker 镜像优化最有效的,不是“到处找更小的基础镜像”,而是先把构建链路理顺。
你可以把本文的结论记成这 5 条:
- 先写好
.dockerignore,减少无效上下文 - 依赖文件先复制,源码后复制,提高层缓存命中率
- 用多阶段构建分离编译与运行环境
- 优先使用
npm ci和 lock 文件,保证依赖稳定 - 在 CI 中启用 BuildKit 缓存挂载或远程缓存,进一步提速
如果你是中级开发者,落地时我建议按这个顺序推进:
- 第一步:补
.dockerignore - 第二步:调整 Dockerfile 指令顺序
- 第三步:引入多阶段构建
- 第四步:启用 BuildKit 缓存
- 第五步:加上非 root 与漏洞扫描
边界条件也要记住:
- 不是所有项目都适合 Alpine
- 不是拆得越细就越快
- 不是镜像越小就一定越安全
- 优化目标应结合 CI 时长、仓库流量、部署频率一起看
一句话收尾:**镜像优化的本质,不只是“瘦身”,而是让构建过程更稳定、可预测、可维护。**只要把这个方向抓住,你的 Dockerfile 基本就不会跑偏。