Docker 多阶段构建与镜像瘦身实战:从构建加速到生产环境安全落地
很多团队刚把服务容器化时,最容易忽略的一件事就是:镜像能跑,不代表适合上线。
我见过不少项目的 Dockerfile 是这样起步的:
- 基础镜像直接用
ubuntu:latest - 构建工具、源码、缓存、测试文件全打进最终镜像
- 容器里默认用 root 用户
- 一次
COPY . .把整个仓库复制进去 - 构建慢、镜像大、漏洞多,线上排查还费劲
一开始觉得“先跑起来再说”,等服务一多,问题就一起冒出来了:CI 变慢、镜像仓库膨胀、发布耗时增加、安全扫描一片红。
这篇文章就从这些真实问题出发,带你一步步把 Docker 镜像做小、做快、做得更适合生产环境。
背景与问题
先看几个常见症状:
-
镜像过大
- 一个简单 Go 服务镜像做到几百 MB
- Node.js 服务动辄 1GB+
- 拉取镜像慢,发布窗口变长
-
构建速度慢
- 代码改一行,依赖全量重装
- CI 每次从头构建
- 没有利用 Docker layer cache
-
生产环境暴露面大
- 镜像里带编译器、包管理器、shell 工具
- root 权限运行
- 把
.env、测试数据甚至私钥打进镜像
-
难以审计与维护
- Dockerfile 层次混乱
- 构建和运行环境混在一起
- 无法快速定位“这个文件为什么在镜像里”
本质上,这些问题都指向一个核心:没有把“构建阶段”和“运行阶段”分离。
前置知识与环境准备
建议你本地准备好以下环境:
- Docker 20.10+
- 启用 BuildKit(推荐)
- 一个简单的示例项目
开启 BuildKit
Linux / macOS 下可以临时这样执行:
export DOCKER_BUILDKIT=1
或者在构建时直接加:
DOCKER_BUILDKIT=1 docker build -t demo-app .
BuildKit 对缓存、并行构建、多阶段体验都更好,后面示例会顺手用到。
核心原理
什么是多阶段构建
多阶段构建(Multi-stage Build)就是在一个 Dockerfile 里定义多个 FROM 阶段:
- 前面的阶段负责编译、打包、测试
- 最后的阶段只保留运行所需产物
这样,最终镜像不会带上源码、编译器、缓存目录等“构建垃圾”。
一个直观理解
flowchart LR
A[源码] --> B[构建阶段<br/>安装依赖/编译/测试]
B --> C[产物]
C --> D[运行阶段<br/>仅复制可执行文件]
D --> E[最终生产镜像]
为什么它能同时解决“瘦身 + 提速 + 安全”
1. 瘦身
构建工具链不进入最终镜像,自然更小。
2. 提速
合理拆分 COPY 和 RUN 顺序后,Docker 层缓存可以复用。
3. 安全
最终镜像里少了 shell、编译器、包管理器,攻击面更小。
一个典型的坏例子
先看一个“能跑,但不太适合上线”的 Node.js Dockerfile:
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
这个写法的问题很集中:
COPY . .太早,导致任意代码变更都让npm install缓存失效- 构建依赖、源码、测试文件都进入最终镜像
npm install默认可能装上 devDependencies- 运行时仍使用较重的基础镜像
- 默认 root 用户运行
实战代码:从单阶段到多阶段
下面用一个常见的 Node.js Web 服务做演示。目录大致如下:
demo-node-app/
├── Dockerfile
├── .dockerignore
├── package.json
├── package-lock.json
├── src/
│ └── server.js
└── dist/
示例应用代码
package.json
{
"name": "demo-node-app",
"version": "1.0.0",
"description": "Docker multi-stage demo",
"main": "dist/server.js",
"scripts": {
"build": "mkdir -p dist && cp src/server.js dist/server.js",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}
src/server.js
const express = require('express');
const os = require('os');
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.json({
message: 'hello docker multi-stage build',
hostname: os.hostname(),
node: process.version
});
});
app.listen(port, () => {
console.log(`server listening on ${port}`);
});
第一步:写一个更合理的 .dockerignore
这一步很多人会漏掉,但它的收益非常直接。
.dockerignore
node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore
README.md
coverage
dist
.env
*.local
为什么要加它
因为 Docker 构建时会把“上下文”发送给 daemon。
如果你把整个仓库,包括 .git、本地缓存、测试报告、环境变量文件都传过去:
- 构建会变慢
- 镜像缓存可能频繁失效
- 还可能把敏感信息带入构建环境
第二步:使用多阶段构建
生产可用版 Dockerfile
# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY src ./src
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
逐段拆解这份 Dockerfile
1. builder 阶段负责构建
FROM node:18-alpine AS builder
这里用 node:18-alpine,比完整版更轻。
但我先提醒一句:不是所有场景都适合 alpine。后面“常见坑”会讲。
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
这一步是构建提速关键点:
- 先复制依赖描述文件
- 再执行
npm ci - 如果只是业务代码改了,依赖层缓存还能复用
COPY src ./src
RUN npm run build
只在依赖安装完成后复制源码,避免不必要的缓存失效。
2. runner 阶段只保留运行所需内容
FROM node:18-alpine AS runner
重新起一个干净阶段,和构建环境分离。
ENV NODE_ENV=production
告诉应用当前是生产模式,很多框架会按这个变量做优化。
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev && npm cache clean --force
这里显式只安装生产依赖,不把开发依赖带入最终镜像。
COPY --from=builder /app/dist ./dist
这就是多阶段构建的核心:
只从构建阶段复制产物,而不是把整个构建环境搬过来。
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
避免 root 运行,这是生产环境非常基础但非常重要的一步。
构建与运行
构建镜像
DOCKER_BUILDKIT=1 docker build -t demo-node-app:1.0 .
运行容器
docker run --rm -p 3000:3000 demo-node-app:1.0
验证服务
curl http://localhost:3000
预期输出类似:
{"message":"hello docker multi-stage build","hostname":"c8d1234abcd","node":"v18.20.0"}
构建流程图
flowchart TD
A[复制 package.json/package-lock.json] --> B[npm ci 安装依赖]
B --> C[复制 src 源码]
C --> D[npm run build]
D --> E[进入 runner 阶段]
E --> F[安装生产依赖]
F --> G[复制 dist 构建产物]
G --> H[切换非 root 用户]
H --> I[启动服务]
第三步:观察镜像瘦身效果
你可以分别构建“坏例子”和“多阶段版本”,然后对比:
docker images | grep demo-node-app
进一步看镜像历史:
docker history demo-node-app:1.0
如果想看镜像里到底塞了什么,我常用这两个命令:
docker run --rm -it demo-node-app:1.0 sh
du -sh /app/*
这样能快速判断:
- 有没有把源码、测试目录带进去
- 有没有不该存在的缓存
- 运行阶段是否真的足够干净
进阶:为什么层缓存能显著提升构建速度
Docker 构建并不是“每次都从零开始”,而是按指令一层层生成镜像。
缓存命中示意
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant Cache as Layer Cache
Dev->>Docker: 修改 src/server.js
Docker->>Cache: 检查 COPY package*.json 及 npm ci 层
Cache-->>Docker: 命中缓存
Docker->>Cache: 检查 COPY src 层
Cache-->>Docker: 缓存失效
Docker->>Docker: 重新执行 build
Docker-->>Dev: 更快生成新镜像
一个经验法则
在 Dockerfile 里,把变化频率低的步骤放前面,把变化频率高的步骤放后面。
例如:
- 依赖文件变化频率低
- 业务代码变化频率高
所以通常顺序应是:
- 复制依赖描述文件
- 安装依赖
- 复制源码
- 构建
这也是为什么不推荐一上来就 COPY . .。
常见坑与排查
这一部分我尽量讲得“接地气”一点,很多坑我自己也踩过。
坑 1:多阶段了,但镜像还是很大
常见原因
- 最终阶段仍然
COPY . . - 安装了 devDependencies
- 基础镜像本身过大
- 运行阶段仍保留包缓存、临时文件
排查方法
docker history your-image:tag
docker run --rm -it your-image:tag sh
看几个重点目录:
du -sh /app
du -sh /root/.npm
du -sh /usr/local/lib/node_modules
修复建议
- 只复制运行必需文件
- 使用
npm ci --omit=dev - 清理包缓存
- 优先选更轻量的运行时镜像
坑 2:用了 Alpine,结果某些依赖编译失败
这是很典型的问题。
alpine 使用的是 musl libc,有些原生依赖、预编译二进制、企业内部库可能和它不兼容。
表现
npm install卡在 native module 编译- 运行时报找不到动态库
- 某些加密、图像、数据库驱动行为异常
排查思路
先别急着改业务代码,优先验证是不是基础镜像兼容性问题:
FROM node:18-bullseye-slim
如果切到 Debian slim 系列问题消失,那大概率就是 Alpine 兼容性导致的。
建议
- 能用
alpine再用 - 如果依赖 native module,优先测试
debian-slim - 镜像小不是唯一目标,稳定更重要
坑 3:构建缓存没生效
常见原因
- 先
COPY . .,导致任何改动都让依赖层失效 - 锁文件频繁变化
- CI 每次都在全新环境,没有远程缓存
- 没启用 BuildKit
排查命令
DOCKER_BUILDKIT=1 docker build --progress=plain -t demo-node-app:1.0 .
观察日志里哪些步骤显示 CACHED。
建议
- 拆开
COPY package*.json和COPY src - 尽量使用锁文件
- CI 引入 registry cache 或 buildx cache
坑 4:容器能启动,但权限报错
你切换到非 root 用户后,可能会遇到:
- 无法写日志目录
- 无法创建临时文件
- 某些生成目录权限不足
处理方式
在切换用户前就把目录权限准备好:
RUN mkdir -p /app/logs && chown -R appuser:appgroup /app
USER appuser
如果是挂载卷带来的权限问题,则需要结合宿主机 UID/GID 一起处理。
坑 5:把敏感文件打进镜像
最容易中招的是:
.env- 私钥
- 测试数据库配置
- 内网证书
排查方法
进入镜像后直接搜:
find /app -maxdepth 2 -type f
建议
- 用
.dockerignore明确排除 - 不把密钥写进镜像
- 配置通过环境变量、Secret、Kubernetes Secret、Vault 等注入
安全/性能最佳实践
这一节给你一份更接近生产落地的清单。
1. 使用明确版本标签,不要依赖 latest
不推荐:
FROM node:latest
推荐:
FROM node:18.20-alpine
这样构建结果更可控,也更容易审计。
2. 运行时镜像尽量最小化
如果你的应用最终只需要一个二进制,比如 Go、Rust,完全可以把运行阶段做到非常小。
例如 Go 服务常见写法:
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app .
FROM scratch
COPY --from=builder /src/app /app
ENTRYPOINT ["/app"]
这种场景下,多阶段构建收益尤其明显。
3. 坚持非 root 运行
至少做到:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
如果业务确实需要绑定低端口、写特定目录,再单独评估权限,不要图省事全程 root。
4. 只安装生产依赖
Node.js 常见做法:
RUN npm ci --omit=dev
Python、Java、Go 也有类似思路:
不要把测试框架、构建插件、调试工具带进生产镜像。
5. 做漏洞扫描,但别只盯 CVE 数量
建议在流水线加入镜像扫描工具,例如:
- Trivy
- Grype
- Docker Scout
但我实际经验是:
不要只看“漏洞个数”,要结合可利用性、运行路径、修复成本判断优先级。
比如:
- 基础镜像中的某个包有 CVE,但运行时根本不会触发
- 而你的应用配置错误、root 运行、暴露调试端口,反而更危险
6. 减少无意义层和中间文件
例如包安装与清理尽量放在同一层里完成,避免清理不彻底:
RUN npm ci --omit=dev && npm cache clean --force
如果拆成两层,前一层产生的缓存仍然可能保留在历史层中。
7. 配合健康检查与只读文件系统
如果运行平台支持,建议加健康检查:
HEALTHCHECK --interval=30s --timeout=3s CMD node -e "require('http').get('http://localhost:3000', res => process.exit(res.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"
同时在运行平台层面尽量启用:
readOnlyRootFilesystem- 限制 Linux Capabilities
- CPU / 内存限制
- seccomp / AppArmor 配置
一个更接近生产的 Dockerfile 示例
下面给一份稍完整一点的 Node.js 版本,适合你在项目里直接改造:
# syntax=docker/dockerfile:1.4
FROM node:18-bullseye-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
FROM node:18-bullseye-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json package-lock.json ./
COPY src ./src
RUN npm run build
FROM node:18-bullseye-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
&& npm cache clean --force
COPY --from=builder /app/dist ./dist
RUN groupadd -r appgroup && useradd -r -g appgroup appuser \
&& chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
这个版本的思路是:
deps:专门处理依赖builder:利用依赖产物做构建runner:只保留运行所需内容
在中大型项目里,这样分层更清晰。
逐步验证清单
如果你打算把现有项目迁移到多阶段构建,我建议按这个顺序验证:
功能正确性
- 容器能正常启动
- 核心接口访问正常
- 配置通过环境变量注入正常
镜像瘦身
- 最终镜像不包含源码
- 不包含测试文件和构建缓存
- 不包含开发依赖
构建性能
- 修改业务代码时依赖层缓存命中
- CI 构建耗时下降
- 镜像推送和拉取耗时下降
安全基线
- 非 root 用户运行
- 没有敏感文件进入镜像
- 基础镜像版本固定
- 完成基础漏洞扫描
生产落地建议
如果你现在维护的是已有项目,不必一口气“全都重构”。比较稳妥的推进方式是:
- 先补
.dockerignore - 再拆分
COPY顺序,拿到缓存收益 - 引入多阶段构建
- 切换非 root 用户
- 最后做基础镜像和依赖优化
这样改造风险更可控,收益也能一层层看到。
如果你的服务构建特别复杂,比如:
- 前端 + 后端混合构建
- 需要私有仓库认证
- 依赖 native 扩展
- 构建过程包含代码生成
那就建议先单独梳理“构建产物边界”:
最终镜像到底只需要哪些文件?
这个边界一旦清楚,多阶段构建就不会乱。
总结
Docker 多阶段构建的价值,不只是“镜像变小”这么简单,它同时影响三件关键的事:
- 构建速度:通过合理利用层缓存减少重复安装
- 运行效率:镜像更小,分发和启动更快
- 生产安全:减少工具链、降低攻击面、避免 root 运行
如果你只记住三条,我建议是:
- 构建和运行环境一定分离
- 先复制依赖描述文件,再安装依赖,最后复制源码
- 最终镜像只保留运行所需文件,并用非 root 用户启动
边界条件也要记住:
不是所有项目都适合盲目追求最小镜像,像 Alpine 兼容性、调试便利性、团队维护成本,都要一起评估。小、快、安全 这三件事,最好是平衡,而不是偏执。
当你把 Dockerfile 当成“生产制品定义文件”来写,而不是“能跑就行的脚本”,镜像质量通常就会有明显提升。