Docker 多阶段构建与镜像瘦身实战:从构建缓存到生产环境安全优化
容器已经很常见了,但很多团队的 Dockerfile 还停留在“能跑就行”的阶段。结果往往是:
- 镜像体积大,拉取慢,发布慢
- 构建过程一改代码就全量重来
- 生产镜像里混着编译器、包管理器、调试工具
- 安全面暴增,漏洞扫描一片红
这篇文章我会带你从一个“能用但臃肿”的 Dockerfile 出发,一步一步改造成:
- 构建更快
- 镜像更小
- 运行更安全
- 排障更清晰
重点会放在 多阶段构建、构建缓存利用、镜像瘦身技巧 和 生产环境安全优化 上。文章以 Node.js 示例为主,因为它很典型,也很容易暴露问题;但里面的方法对 Go、Java、Python、前端项目都适用。
背景与问题
先看一个团队里很常见的 Dockerfile 写法:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
它的问题不少:
-
COPY . .太早
只要项目里任意文件变化,后面的npm install就失去缓存。 -
开发文件全进镜像
例如测试文件、.git、本地缓存、文档、日志,统统带进去。 -
生产镜像含构建工具链
npm、编译依赖、甚至构建中间产物都留在最终镜像里。 -
默认 root 运行
万一容器被利用,风险更高。 -
镜像层数和内容没有控制
体积大,漏洞面也大。
如果项目稍微大一点,你会很快遇到几个现实问题:
- CI 一次构建动辄几分钟
- 镜像几百 MB 甚至上 GB
- 上线时拉镜像耗时长
- 漏洞扫描报告里有几十个“高危”,但多数来自根本不需要的构建依赖
前置知识与环境准备
建议你至少具备以下基础:
- 会写基础 Dockerfile
- 知道镜像、容器、层(layer)是什么
- 本地安装 Docker 20.10+,最好启用 BuildKit
建议开启 BuildKit,后面缓存能力会更好用:
export DOCKER_BUILDKIT=1
如果是 Docker Desktop,一般默认已经启用。
核心原理
在开始改造前,先把几个关键原理讲透。理解这些后,你写 Dockerfile 就不会只靠“背模板”。
1. Docker 构建缓存是按层工作的
Dockerfile 中每条指令大致都会形成一层。某一层是否能复用缓存,取决于:
- 指令本身是否变化
- 指令依赖的文件内容是否变化
比如:
COPY package*.json ./
RUN npm ci
COPY . .
这比先 COPY . . 再 npm ci 好得多。因为当你只改业务代码,不改依赖描述文件时,npm ci 这一层可以直接走缓存。
2. 多阶段构建的核心:把“构建环境”和“运行环境”分开
多阶段构建本质上是在一个 Dockerfile 中定义多个阶段:
- builder 阶段:安装依赖、编译、打包
- runtime 阶段:只保留运行所需内容
你可以把前面阶段产出的文件,按需复制到后面阶段,而不是把整个环境都带过去。
3. 镜像瘦身不只是为了“省空间”
体积小带来的收益不止是磁盘占用:
- 镜像拉取更快
- 节点扩容更快
- 漏洞面更小
- 审计和排障更简单
- 冷启动更短
4. 安全优化和瘦身常常是同方向的
例如:
- 去掉编译器、包管理器、shell 工具
- 不以 root 身份运行
- 使用更小、更专用的基础镜像
- 只复制运行必需文件
这些操作既会减少镜像体积,也会缩小攻击面。
一图看懂:从臃肿构建到多阶段发布
flowchart LR
A[源代码目录] --> B[单阶段构建]
B --> C[安装全部依赖]
C --> D[构建产物]
D --> E[最终镜像包含源码/构建工具/依赖]
A --> F[多阶段构建]
F --> G[builder 安装依赖并编译]
G --> H[runtime 仅复制运行所需文件]
H --> I[最终镜像更小更安全]
实战代码:从“能跑”到“适合生产”
下面我用一个简单的 Node.js 应用演示完整过程。
示例项目结构
demo-app/
├── src/
│ └── index.js
├── package.json
├── package-lock.json
├── .dockerignore
└── Dockerfile
示例应用代码
src/index.js:
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.json({
message: 'hello docker',
time: new Date().toISOString()
});
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`server started on ${port}`);
});
package.json:
{
"name": "demo-app",
"version": "1.0.0",
"description": "docker multi-stage demo",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
先生成锁文件:
npm install
第一步:先写一个“普通但较规范”的 Dockerfile
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY src ./src
EXPOSE 3000
CMD ["npm", "start"]
这个版本已经比最开始那个示例强不少:
- 先复制依赖描述文件,便于缓存
- 使用
npm ci,更适合 CI 和可重复构建 - 不再直接
COPY . .
构建并运行:
docker build -t demo-app:v1 .
docker run -p 3000:3000 demo-app:v1
验证:
curl http://localhost:3000
第二步:加入 .dockerignore,先砍掉无关文件
这个步骤特别容易被忽略,但收益非常大。构建上下文越大,Docker 发送给守护进程的内容越多,构建越慢,缓存也越容易失效。
.dockerignore:
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
coverage
dist
.env
为什么这一步很重要?
如果没有 .dockerignore,以下内容可能都被打进构建上下文:
- 本地
node_modules - Git 历史
- 测试报告
- 编辑器临时文件
- 本地构建产物
这些内容有些不会直接进入最终镜像,但会影响构建速度和缓存命中。我自己就踩过这个坑:项目根目录里一个频繁变化的日志文件,让缓存几乎次次失效。
第三步:使用多阶段构建
如果项目需要编译,比如 TypeScript、前端项目、原生扩展模块,单阶段就不太合适了。下面给出一个更接近生产的多阶段版本。
多阶段 Dockerfile 示例
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM node:18-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build || echo "no build step"
FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=build /app/src ./src
USER node
EXPOSE 3000
CMD ["npm", "start"]
这里示例项目没有真正的打包步骤,所以
npm run build || echo "no build step"只是为了兼容演示。真实项目里请替换成明确的构建命令。
这个写法解决了什么?
deps阶段负责完整依赖安装,便于复用build阶段用于编译/打包runtime阶段只安装生产依赖并复制运行需要的内容- 最终镜像中不包含构建阶段的大量中间文件
- 容器使用
node用户运行,而不是 root
多阶段构建流程图
flowchart TD
A[package.json package-lock.json] --> B[deps 阶段: npm ci]
B --> C[build 阶段: 复制源码并构建]
C --> D[runtime 阶段]
A --> D
D --> E[仅安装生产依赖]
C --> F[复制构建产物/运行文件]
E --> G[最终生产镜像]
F --> G
第四步:更进一步,针对真正有构建产物的项目
如果你的项目是 TypeScript 或前端构建,推荐把“构建结果”和“运行环境”彻底分离。下面是一个更典型的版本。
TypeScript/前端风格 Dockerfile
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:18-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]
这样最终镜像里通常只包含:
dist/- 生产依赖
- 少量启动配置
而不会包含:
- 源码
- 测试代码
- 编译工具链
- 开发依赖
第五步:利用 BuildKit 缓存,加速依赖安装
很多人以为优化缓存就是调整 COPY 顺序,其实在 BuildKit 下还能做得更好。
使用缓存挂载
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
构建时:
DOCKER_BUILDKIT=1 docker build -t demo-app:buildkit .
这和普通缓存有什么区别?
普通 Docker 层缓存的特点是:某层失效就要重跑。
而 BuildKit 的缓存挂载可以让像 npm、pip、apt 这种包管理器复用下载缓存,即使这一层需要重新执行,也不一定重新下载所有内容。
对于依赖很多的项目,这能明显缩短 CI 时间。
构建缓存命中逻辑时序图
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant Cache as 构建缓存
participant NPM as npm registry/cache
Dev->>Docker: 修改业务代码后执行 docker build
Docker->>Cache: 检查 COPY package*.json 与 RUN npm ci 层
Cache-->>Docker: 依赖层未变化,命中缓存
Docker->>Docker: 执行后续 COPY src
Docker->>Docker: 生成新应用层
Dev->>Docker: 修改 package-lock.json 后再次构建
Docker->>Cache: 检查依赖层
Cache-->>Docker: 缓存失效
Docker->>NPM: 重新安装依赖
第六步:验证镜像是否真的变小了
你不能只“感觉它更优雅了”,最好做实际验证。
查看镜像体积
docker images | grep demo-app
查看镜像层历史
docker history demo-app:v1
docker history demo-app:buildkit
对比构建耗时
简单粗暴一点:
time docker build -t demo-app:test .
查看容器内实际文件
docker run --rm -it demo-app:test sh
进入后检查:
ls -lah
du -sh /app
如果你发现镜像里还有:
.git- 测试目录
- 文档
- 编译缓存
- shell 工具一大堆
那说明瘦身还没做到位。
可运行的完整生产版示例
下面给一个适合多数 Node.js 服务的完整版本。
Dockerfile
# 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 build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build || echo "skip build"
FROM node:18-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --only=production \
&& npm cache clean --force
COPY --from=build /app/src ./src
RUN chown -R node:node /app
USER node
EXPOSE 3000
CMD ["npm", "start"]
.dockerignore
node_modules
.git
.gitignore
Dockerfile
.dockerignore
README.md
coverage
dist
.env
*.log
构建命令
DOCKER_BUILDKIT=1 docker build -t demo-app:prod .
运行命令
docker run --rm -p 3000:3000 demo-app:prod
验证命令
curl http://localhost:3000
逐步验证清单
建议你照着下面的清单一项项确认,而不是一股脑改完就上线。
1. 缓存是否生效
修改 src/index.js,重新构建,观察:
npm ci是否复用缓存- 只有应用层重新生成
2. 依赖变化是否触发重装
修改 package.json 或 package-lock.json,重新构建,确认依赖层重建。
3. 镜像中是否残留不需要文件
运行容器后进入 shell 检查:
docker run --rm -it demo-app:prod sh
4. 是否以非 root 用户运行
docker exec -it <container_id> id
应看到类似:
uid=1000(node) gid=1000(node)
5. 服务是否仍能正常工作
尤其要验证:
- 端口监听正常
- 配置文件路径正常
- 日志输出正常
- 生产依赖未缺失
常见坑与排查
这部分很重要。很多人会写出“理论上很优雅”的 Dockerfile,但一跑就出问题。
坑 1:COPY . . 导致缓存频繁失效
现象:
- 改个 README 都会触发依赖重装
- 构建很慢
排查:
看 Dockerfile 顺序,是否先 COPY . . 再安装依赖。
修复:
先复制依赖描述文件,再安装依赖,再复制源码。
COPY package*.json ./
RUN npm ci
COPY . .
坑 2:.dockerignore 缺失,构建上下文巨大
现象:
构建时看到:
Sending build context to Docker daemon 800MB
排查:
检查项目目录是否存在:
node_modules.git- 大文件目录
- 测试输出目录
修复:
补全 .dockerignore。
坑 3:多阶段构建后应用启动失败
现象:
容器启动报错:
Error: Cannot find module ...
原因:
最终阶段没复制必要文件,或者生产依赖没装全。
排查思路:
- 进入容器看目录结构
- 检查
COPY --from=build ...路径是否正确 - 检查运行命令是否仍指向旧路径
修复示例:
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]
坑 4:npm ci --only=production 与实际项目脚本不兼容
现象:
运行时报缺包,但开发环境正常。
原因:
有些项目把运行时需要的包错误地放在 devDependencies。
排查:
检查 package.json 中依赖归类。
修复:
- 运行时需要的包放到
dependencies - 构建工具放到
devDependencies
这个问题我见过很多次,尤其是老项目迁移容器化时最容易暴露。
坑 5:使用 Alpine 后某些原生依赖异常
现象:
- 安装某些 npm 包失败
- 运行时报二进制兼容问题
原因:
Alpine 使用 musl,有些预编译二进制依赖针对 glibc。
排查:
查看报错是否涉及原生模块、动态链接库。
修复建议:
- 能不用原生依赖就不用
- 必要时改用
debian-slim基础镜像 - 不要为了“追求最小”牺牲稳定性
这就是一个很典型的边界条件:最小镜像不一定是最合适镜像。
坑 6:非 root 用户运行后权限报错
现象:
应用无法写日志、创建临时文件或访问目录。
排查:
检查目录属主和写权限。
修复:
RUN chown -R node:node /app
USER node
如果应用必须写某个目录,也要提前授权。
安全/性能最佳实践
下面这部分我建议你直接当作生产清单。
1. 优先使用多阶段构建
把构建依赖和运行环境拆开,是瘦身和安全的起点。
适用边界:
- 几乎所有需要编译、打包、转译的项目都适用
- 纯脚本型项目也建议拆阶段,便于统一规范
2. 只复制必要文件
最终镜像应尽量只包含:
- 运行产物
- 必要配置
- 生产依赖
避免复制:
- 测试代码
- 文档
- 构建缓存
- Git 元数据
3. 固定依赖版本并使用锁文件
例如:
package-lock.jsonyarn.lockpoetry.lockgo.sum
这样能提高构建可重复性,也减少“今天能跑、明天不行”的情况。
4. 使用 npm ci 而不是 npm install
在 CI/CD 场景里:
- 更稳定
- 更可预测
- 与锁文件更一致
5. 生产容器不要默认 root 运行
USER node
或者创建专用用户:
RUN addgroup -S app && adduser -S app -G app
USER app
6. 基础镜像别盲目追求最小,先追求合适
常见选择:
alpine:小,但兼容性要关注debian-slim:更稳,稍大- distroless:更安全更纯粹,但调试不方便
建议:
- 普通业务服务优先
slim或验证过的alpine - 高安全场景可考虑 distroless
- 调试困难时,别硬上过于极致的精简镜像
7. 定期扫描镜像漏洞
例如可用:
docker scan demo-app:prod
或者使用企业常见扫描平台。
注意一点:漏洞数量下降,不只是靠“修补”,更要靠“不把无关软件装进去”。
8. 合理利用层缓存与 BuildKit 缓存挂载
- 依赖安装前只复制锁文件/依赖描述文件
- 使用
--mount=type=cache - 在 CI 中尽量保留可复用缓存
9. 减少镜像层中的无效文件残留
例如某些命令会留下缓存文件:
RUN npm ci --only=production && npm cache clean --force
如果是 apt 场景,也要清理包索引缓存。
10. 区分“构建优化”和“运行优化”
很多人把两者混在一起:
- 构建优化关注:缓存、层顺序、上下文大小
- 运行优化关注:镜像体积、启动速度、安全权限、运行时依赖
这两者相关,但不是一回事。设计 Dockerfile 时最好分开思考。
一个简洁的优化路线图
如果你要在现有项目上落地,我建议按下面顺序改,不要一次改太多:
stateDiagram-v2
[*] --> 补充dockerignore
补充dockerignore --> 调整COPY顺序
调整COPY顺序 --> 引入多阶段构建
引入多阶段构建 --> 切换生产依赖安装
切换生产依赖安装 --> 非root运行
非root运行 --> 引入BuildKit缓存
引入BuildKit缓存 --> 镜像扫描与验证
镜像扫描与验证 --> [*]
总结
把 Docker 镜像做好,关键不在于背多少“高级指令”,而在于建立一套清晰的思路:
- 先控制构建上下文:写好
.dockerignore - 再优化缓存命中:先复制依赖文件,再安装依赖
- 用多阶段构建隔离构建与运行环境
- 最终镜像只保留运行所需内容
- 生产容器尽量非 root 运行
- 结合 BuildKit 做进一步提速
- 用实际体积、构建时长、漏洞扫描结果来验证优化效果
如果你现在的 Dockerfile 还只是“能跑”,最值得先做的三件事是:
- 加上
.dockerignore - 调整
COPY与依赖安装顺序 - 拆成多阶段构建
这三步通常就能立刻看到很明显的收益:构建更快、镜像更小、生产更干净。
如果你的项目有原生依赖、Alpine 兼容性问题、或需要更高安全等级,再继续往 slim / distroless、非 root 用户、漏洞治理方向深入。别一开始就追求“最极致”,先做到“稳定、可验证、可维护”,这才是生产环境里真正有价值的优化。