Docker 多阶段构建与镜像瘦身实战:从构建提速到安全交付的完整优化方案
很多团队刚开始用 Docker 时,最容易犯的一个错误就是:“先把应用跑起来再说”。结果往往是镜像能用,但很重、构建慢、漏洞多,上线后排查问题还费劲。
我自己早期也踩过这个坑。一个很普通的 Java 服务,构建镜像时把 Maven、源码、测试文件、缓存、调试工具全塞进去了,最后镜像接近 1GB。每次 CI 推送像在搬家,安全扫描一跑全是高危依赖,发布效率和交付质量都被拖住了。
这篇文章我不只讲“什么是多阶段构建”,而是带你从一个真实的优化路径走一遍:
- 为什么镜像会又大又慢
- 多阶段构建到底解决了什么
- 怎么写出可运行的 Dockerfile
- 怎么结合缓存提速
- 怎么做安全交付
- 常见坑怎么查
如果你已经会写基础 Dockerfile,但还没有系统整理过构建链路,这篇会比较适合你。
背景与问题
在实际项目里,Docker 镜像膨胀通常来自几个典型原因:
-
构建工具直接进入运行环境
- 比如 Go 镜像里保留了编译器
- Java 镜像里保留了 Maven 和本地仓库
- Node.js 镜像里留着构建依赖和源代码
-
把整个项目目录无脑
COPY . ..git- 本地日志
- 测试数据
node_modules- 编译产物
-
不会利用层缓存
- 依赖文件和业务代码混在一起复制
- 任意一个文件变化,就导致依赖重装
-
基础镜像选型不合理
- 直接用
ubuntu、centos作为运行镜像 - 运行时带了很多根本用不上的系统工具
- 直接用
-
安全边界意识不足
- 容器里默认 root
- 镜像里包含密钥或私有配置
- 缺少漏洞扫描和最小权限控制
这些问题的直接后果通常有三个:
- 构建慢:CI/CD 时间变长
- 传输慢:镜像拉取和发布耗时高
- 风险高:攻击面扩大,漏洞数量上升
前置知识与环境准备
建议你先具备这些基础:
- 会写基础
Dockerfile - 知道镜像分层、容器运行的基本概念
- 本地已安装 Docker 20.10+
- 最好启用 BuildKit
可以先确认环境:
docker version
docker buildx version
启用 BuildKit:
export DOCKER_BUILDKIT=1
核心原理
多阶段构建的关键思想其实很朴素:
把“构建环境”和“运行环境”彻底分开。
比如一个 Go 应用,构建阶段需要:
- Go 编译器
- Git
- 构建缓存
但运行阶段只需要:
- 一个可执行二进制
- 少量运行时依赖
- 非 root 用户
多阶段构建允许我们在一个 Dockerfile 里定义多个阶段:
- 第一阶段:编译
- 第二阶段:测试
- 第三阶段:运行
最后只把需要的产物复制进最终镜像。
一个直观对比
flowchart LR
A[源码] --> B[传统单阶段构建]
B --> C[包含编译器/缓存/源码/产物]
C --> D[镜像大 构建慢 风险高]
A --> E[多阶段构建]
E --> F[构建阶段 负责编译]
F --> G[运行阶段 仅复制产物]
G --> H[镜像小 启动快 风险低]
Docker 分层缓存为什么重要
Docker 每条指令都会形成一层。只要某一层发生变化,后续层通常都要重新构建。
所以 Dockerfile 的顺序很重要:
- 稳定的内容放前面
- 经常变化的内容放后面
例如 Node.js 项目中,应先复制 package.json 和 lock 文件安装依赖,再复制源码。这样只改业务代码时,不会每次都重新安装依赖。
多阶段构建的几个核心收益
- 镜像瘦身
- 减少攻击面
- 更清晰的构建链路
- 便于测试、扫描和发布分离
- 更好地利用缓存
优化思路全景图
在实战里,我一般不是只做“多阶段构建”这一件事,而是把它放进一套完整流程中。
flowchart TD
A[源码提交] --> B[依赖解析与缓存]
B --> C[编译/测试]
C --> D[多阶段构建]
D --> E[生成最小运行镜像]
E --> F[漏洞扫描]
F --> G[签名/推送镜像]
G --> H[部署到环境]
这张图想表达的是:镜像瘦身不是终点,安全交付才是终点。
实战代码:一步步把镜像做小、做快、做安全
下面我用一个 Go Web 服务 做演示。因为 Go 的优化效果直观,代码也容易跑起来。
项目结构
demo-go-app/
├── Dockerfile
├── .dockerignore
├── go.mod
├── go.sum
└── main.go
示例应用代码
main.go:
package main
import (
"fmt"
"net/http"
"os"
)
func handler(w http.ResponseWriter, r *http.Request) {
hostname, _ := os.Hostname()
fmt.Fprintf(w, "hello from %s\n", hostname)
}
func main() {
http.HandleFunc("/", handler)
fmt.Println("server started at :8080")
http.ListenAndServe(":8080", nil)
}
go.mod:
module demo-go-app
go 1.22
先看一个不推荐的单阶段 Dockerfile
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o app .
EXPOSE 8080
CMD ["./app"]
这个版本的问题很典型:
- 最终镜像里带有完整 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/app .
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/app /app/app
EXPOSE 8080
CMD ["/app/app"]
这个版本已经完成了最核心的优化:
- 构建工具只存在于
builder阶段 - 最终镜像只保留二进制
- 镜像体积通常会明显下降
构建并运行
docker build -t demo-go-app:v1 .
docker run --rm -p 8080:8080 demo-go-app:v1
验证:
curl http://localhost:8080
进一步优化:缓存、最小权限、可追踪性
上面的版本能用,但还不够“工程化”。下面是更推荐的版本。
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /out/app .
FROM alpine:3.20 AS runtime
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /out/app /app/app
USER app
EXPOSE 8080
ENTRYPOINT ["/app/app"]
这里做了哪些增强?
1. 启用了 BuildKit 缓存挂载
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
这样依赖缓存不会每次都重新下载,CI 里效果尤其明显。
2. 使用 -ldflags="-s -w" 压缩二进制
go build -ldflags="-s -w" -o /out/app .
可以减少符号信息和调试信息,降低体积。
3. 运行阶段使用非 root 用户
RUN addgroup -S app && adduser -S app -G app
USER app
这是容器安全里非常基础但非常重要的一步。
更进一步:使用 distroless 运行镜像
如果你的应用是静态编译的,还可以把运行镜像换成更小、更干净的基础镜像。
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /out/app .
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /out/app /app/app
EXPOSE 8080
ENTRYPOINT ["/app/app"]
distroless 的优点
- 镜像更小
- 几乎没有多余工具
- 攻击面更小
但它有边界条件
- 没有 shell,调试不方便
- 依赖动态库的程序要额外确认兼容性
- 某些故障排查场景不如 Alpine 直接
所以我的建议是:
- 开发/联调环境:可以先用 Alpine
- 生产环境:优先评估 distroless
.dockerignore 是瘦身中最容易被忽略的一环
很多人把重点都放在 Dockerfile,结果忘了 .dockerignore。其实它经常能直接减少构建上下文大小。
.dockerignore 示例:
.git
.gitignore
Dockerfile
README.md
*.log
tmp/
dist/
coverage/
.idea/
.vscode/
node_modules/
为什么它重要?
Docker 在构建前,会把上下文发送给 Docker daemon。
如果项目目录很大,你每次构建都在传输一堆无关文件。
排查时可以观察这行输出:
[internal] load build context
如果这里显示几十 MB、几百 MB,就该优先检查 .dockerignore 了。
分阶段测试与发布
多阶段构建不只是“builder + runtime”两段。你还可以加入 test 阶段,把测试也纳入镜像构建流程。
# syntax=docker/dockerfile:1.7
FROM golang:1.22 AS deps
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
FROM deps AS test
WORKDIR /src
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go test ./...
FROM deps AS builder
WORKDIR /src
COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o /out/app .
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /out/app /app/app
ENTRYPOINT ["/app/app"]
只跑测试阶段:
docker build --target test -t demo-go-app:test .
构建最终运行镜像:
docker build -t demo-go-app:prod .
这套方式的优点很明显:
- 测试、编译、运行环境边界清晰
- CI 可以更细粒度控制失败点
- 发布镜像中不会混入测试工具
逐步验证清单
我建议你每次优化 Dockerfile 时,不要一把梭,而是按下面清单验证。
验证 1:镜像大小是否下降
docker images | grep demo-go-app
验证 2:运行镜像里是否只剩必要文件
docker run --rm --entrypoint ls demo-go-app:v1 -l /app
如果你用的是 distroless,这一步通常不方便,因为没有 shell 和常用命令。
这也是为什么我前面说:生产用 distroless,调试用 Alpine 是一种很实用的折中方案。
验证 3:是否以非 root 运行
docker run --rm --entrypoint id demo-go-app:v1
如果镜像没有 id 命令,可以改为在应用日志中输出 UID,或者通过运行时安全策略验证。
验证 4:构建缓存是否生效
连续执行两次:
docker build -t demo-go-app:cache-test .
观察依赖下载和编译步骤是否命中缓存。
构建过程时序图
下面这张图适合帮助你理解 BuildKit、多阶段和最终交付之间的关系。
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker BuildKit
participant Builder as Builder阶段
participant Runtime as Runtime阶段
participant Registry as 镜像仓库
Dev->>Docker: docker build
Docker->>Builder: 拉取构建基础镜像
Builder->>Builder: 下载依赖并编译
Builder-->>Docker: 产出二进制文件
Docker->>Runtime: 创建最小运行镜像
Runtime-->>Docker: 复制运行产物
Docker->>Registry: 推送最终镜像
常见坑与排查
这一节我尽量写得“接地气”一点,因为这些问题都很常见。
1. 多阶段写了,但镜像还是很大
常见原因
- 最终阶段仍然用了很重的基础镜像
COPY . .把无关文件带进 builder,构建产物间接变大- 运行阶段复制了整个目录,而不是单个二进制
错误示例
COPY --from=builder /src /app
这会把源码、缓存、测试文件都拷过去。
正确示例
COPY --from=builder /out/app /app/app
2. 构建缓存总是失效
常见原因
- 先
COPY . .,再安装依赖 - lock 文件频繁变化
- 构建参数变化导致缓存失效
优化顺序
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /out/app .
对于 Node.js、Java、Python 也同理:
- 先复制依赖声明文件
- 再安装依赖
- 最后复制业务代码
3. distroless 镜像里进不去,没法调试
这是正常现象,不是你写错了。
解决办法
- 保留一个 debug 版本镜像
- 使用 Alpine 作为临时调试基础镜像
- 在 Kubernetes 中用临时调试容器排查
比如保留两个目标阶段:
FROM alpine:3.20 AS debug
WORKDIR /app
COPY --from=builder /out/app /app/app
ENTRYPOINT ["/app/app"]
FROM gcr.io/distroless/static-debian12:nonroot AS prod
WORKDIR /app
COPY --from=builder /out/app /app/app
ENTRYPOINT ["/app/app"]
构建调试版:
docker build --target debug -t demo-go-app:debug .
构建生产版:
docker build --target prod -t demo-go-app:prod .
4. 应用在 Alpine 能跑,到 distroless 或 scratch 就挂了
典型原因
- 程序依赖动态链接库
- 证书文件缺失
- 时区文件缺失
- CGO 依赖没有处理好
排查建议
先检查二进制是否静态链接:
file app
或者在构建阶段查看:
ldd /out/app
如果是 Go 程序,优先尝试:
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app .
如果你的应用必须使用动态库,那就不要强行上 scratch,要基于实际依赖选运行镜像。
5. 非 root 用户运行后,文件权限报错
常见现象
- 启动时报
permission denied - 应用无法写临时目录
- 日志目录不可写
处理方式
在镜像里提前创建目录并授权:
RUN mkdir -p /app/logs && chown -R app:app /app
USER app
另外,能不写容器文件系统就尽量不写。
日志优先输出 stdout/stderr,临时文件优先挂载卷。
安全最佳实践
镜像瘦身和安全交付天然是同一条路上的事。镜像越小,通常攻击面越小;但“体积小”不等于“安全”,还要补齐下面这些动作。
1. 使用可信、明确版本的基础镜像
避免这样写:
FROM alpine:latest
更推荐:
FROM alpine:3.20
进一步可以固定 digest:
FROM alpine:3.20@sha256:xxxxxxxxxxxxxxxx
这样能减少“同名标签内容变了”的不确定性。
2. 永远不要把密钥打进镜像
错误方式:
ENV ACCESS_KEY=xxxxx
ENV SECRET_KEY=yyyyy
镜像层是可追溯的,这类信息非常容易泄露。
正确做法:
- 构建时使用 secret mount
- 运行时通过环境变量或密钥管理系统注入
- 不把
.env直接复制进镜像
如果用 BuildKit,可以这样传 secret:
docker build --secret id=mytoken,src=./token.txt -t demo-go-app:secure .
Dockerfile 中使用:
RUN --mount=type=secret,id=mytoken \
cat /run/secrets/mytoken > /dev/null
3. 运行时最小权限
建议至少做到:
- 非 root 用户
- 只读文件系统(视应用而定)
- 只开放必要端口
- 去掉不必要 Linux capability
例如运行时:
docker run --read-only --cap-drop ALL -p 8080:8080 demo-go-app:prod
当然,是否能完全只读,取决于你的应用是否需要写缓存、上传文件或临时目录。
4. 做漏洞扫描
交付前最好把扫描接入 CI。常见工具有:
- Trivy
- Grype
- Docker Scout
例如用 Trivy 扫描本地镜像:
trivy image demo-go-app:prod
需要注意一点:
扫描结果不是越多越说明你做得差,而是要区分运行时真正相关的风险。
多阶段构建的价值就在于,能把很多只属于构建阶段的依赖排除在最终镜像外。
5. 建立 SBOM 和镜像签名
在更严格的交付场景里,建议增加:
- SBOM(软件物料清单)
- 镜像签名
- 来源可追踪
这能帮助你回答两个关键问题:
- 这个镜像里到底包含了什么?
- 这个镜像是不是我们自己构建并发布的?
性能最佳实践
除了镜像变小,我更关心“整个交付链路是否变快”。
1. 拆分依赖层和源码层
这是最基础也最有效的一招。
2. 使用 BuildKit 缓存挂载
对 Go、Node.js、Maven、pip 都有效。
3. 减少无意义文件进入上下文
优先治理 .dockerignore。
4. 尽量减少层内无关操作
比如不要在多个 RUN 中重复安装和清理同一套包。
5. 选择适合的基础镜像,而不是一味追求最小
- 追求极致最小:
scratch - 兼顾简洁与兼容:distroless
- 兼顾调试便利:Alpine
我自己的经验是:
- 不是所有项目都适合
scratch - distroless 是大多数生产服务的好平衡点
- 如果团队排障经验不足,先从 Alpine 过渡更稳
不同方案取舍对比
| 方案 | 镜像大小 | 调试便利 | 安全性 | 适用场景 |
|---|---|---|---|---|
| 单阶段构建 | 大 | 高 | 低 | 本地临时验证 |
| 多阶段 + Alpine | 中 | 中高 | 中高 | 大多数业务服务 |
| 多阶段 + distroless | 小 | 低 | 高 | 生产环境 |
| 多阶段 + scratch | 最小 | 最低 | 高 | 静态编译且依赖简单的程序 |
这里没有绝对正确答案,只有更适合当前团队能力和应用类型的方案。
一套可直接落地的建议模板
如果你想快速在团队内推广,我建议按下面顺序实施:
- 先补
.dockerignore - 把单阶段改成多阶段
- 按依赖层/源码层重排 Dockerfile
- 引入 BuildKit 缓存
- 运行镜像切换为非 root
- 接入漏洞扫描
- 再评估 distroless 或更小基础镜像
这个顺序的好处是:
- 改动成本低
- 风险可控
- 每一步都能看到收益
- 不容易“一次改太多导致排查困难”
总结
Docker 多阶段构建的核心,不只是把镜像做小,而是把构建、运行、安全交付这三件事重新梳理清楚。
你可以把本文记成三句话:
- 构建工具不要进入运行镜像
- 依赖层要稳定,业务层要后置
- 瘦身不是终点,安全交付才是终点
如果你现在手上的 Dockerfile 还比较“原始”,最值得立刻执行的动作是:
- 加
.dockerignore - 改成多阶段构建
- 最终镜像只复制运行产物
- 使用非 root 用户
- 接入扫描工具验证结果
最后补一句边界条件:
不是所有服务都要追求最小镜像。 如果你的团队当前更缺的是排障效率,而不是极致体积,那么先选 Alpine 这类更好调试的运行镜像,通常更实际。等链路稳定后,再逐步收敛到 distroless 或更严格的生产镜像方案,这样落地成功率反而更高。