Docker 多阶段构建与镜像瘦身实战:从构建提速到安全优化的完整方案
很多团队一开始用 Docker 时,能跑就行:Dockerfile 里一股脑装依赖、编译、打包、启动,最后镜像能有多大算多大。等服务多了、CI 变慢了、镜像仓库越来越胖、漏洞扫描一片红,问题就集中爆发了。
我自己第一次认真做镜像治理时,最直观的感受就是:镜像瘦身不是“省几十 MB”这么简单,它会直接影响构建速度、发布效率、攻击面和运维成本。 而多阶段构建(Multi-stage Build)就是这件事里最实用的一把刀。
这篇文章我会带你从问题出发,拆清楚多阶段构建的核心原理,然后给出一套可运行的实战示例,包括:
- 为什么镜像会越来越胖
- 多阶段构建到底解决了什么
- 如何一步步把镜像从“开发机打包机”改造成“最小运行时”
- 如何配合
.dockerignore、缓存、非 root 用户、安全基线做完整优化 - 常见坑怎么排查
背景与问题
先看一个很常见的“原始版” Dockerfile。以 Go 服务为例:
FROM golang:1.21
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o app .
EXPOSE 8080
CMD ["./app"]
它的问题很典型:
- 构建环境和运行环境混在一起
- Go 编译器、包管理工具、缓存文件都被带进最终镜像
- 镜像体积大
- 仅
golang基础镜像就不小
- 仅
- 安全面更大
- 镜像里工具越多,被利用的可能性越高
- 构建缓存利用率差
COPY . .放得太早,代码一改,后续层缓存几乎全失效
- 不利于多环境发布
- 测试、构建、生产往往应该关注点不同,但这里全混在一起了
一个典型的演进问题链
flowchart TD
A[单阶段 Dockerfile] --> B[镜像包含编译器/依赖缓存]
B --> C[镜像体积膨胀]
B --> D[漏洞扫描项增加]
C --> E[拉取/推送变慢]
E --> F[CI/CD 变慢]
D --> G[安全治理成本上升]
如果你现在的项目已经出现以下症状,那基本就是该重构镜像了:
- CI 构建越来越慢
- 镜像动辄几百 MB 甚至上 GB
- 漏洞扫描结果大量来自“其实运行时根本用不到”的包
- 开发环境可用,线上启动却慢、排查也复杂
前置知识与环境准备
建议你本地准备以下环境:
- Docker 20.10+
- 推荐启用 BuildKit
- 一个可运行的 Go 项目示例
启用 BuildKit:
export DOCKER_BUILDKIT=1
如果你使用 Docker Desktop,通常已经默认支持。
本文实战示例目录如下:
demo-go-app/
├── Dockerfile
├── .dockerignore
├── go.mod
├── go.sum
└── main.go
示例代码
main.go:
package main
import (
"encoding/json"
"log"
"net/http"
"os"
)
func main() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"host": hostname(),
})
})
log.Println("server started at :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
func hostname() string {
h, err := os.Hostname()
if err != nil {
return "unknown"
}
return h
}
go.mod:
module demo-go-app
go 1.21
.dockerignore:
.git
.gitignore
README.md
Dockerfile
tmp
dist
node_modules
*.log
核心原理
多阶段构建的核心思路可以概括成一句话:
在前面的阶段里“尽情构建”,在最后的阶段里“只保留运行必需品”。
也就是说:
- builder 阶段:安装编译工具、拉依赖、构建产物
- runtime 阶段:只拷贝二进制文件或打包结果,不带编译环境
多阶段构建的结构
flowchart LR
A[源码] --> B[builder 阶段]
B --> C[下载依赖]
C --> D[编译产物]
D --> E[runtime 阶段]
E --> F[仅复制可执行文件]
F --> G[最终瘦身镜像]
为什么它能同时提升性能和安全
因为它把“构建需要”和“运行需要”分开了:
- 构建阶段可能需要:
- 编译器
- 包管理器
- 头文件
- 调试工具
- 运行阶段通常只需要:
- 可执行文件
- 必要证书
- 少量运行时依赖
结果是:
- 镜像更小
- 层更少
- 漏洞暴露更少
- 启动和分发更快
Docker 层缓存为什么很关键
Docker 每一步都会形成一层。只要某一层的输入变了,后续层通常都要重新执行。所以 Dockerfile 指令顺序直接影响构建速度。
正确思路一般是:
- 先复制依赖描述文件
- 安装依赖
- 再复制业务代码
- 最后编译
这样改业务代码时,不会导致依赖下载层失效。
实战代码(可运行)
下面我们从“普通版”一步步改到“生产版”。
第一步:一个可工作的单阶段版本
先看对照组:
FROM golang:1.21-alpine
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o app .
EXPOSE 8080
CMD ["./app"]
构建:
docker build -t demo-go:single .
运行:
docker run --rm -p 8080:8080 demo-go:single
验证:
curl http://localhost:8080/health
虽然能跑,但这还不是我们想要的结果。
第二步:改造成多阶段构建
这是一个更接近生产可用的版本:
FROM golang:1.21-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /app/app .
FROM alpine:3.18 AS runtime
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/app /app/app
USER appuser
EXPOSE 8080
ENTRYPOINT ["/app/app"]
构建:
docker build -t demo-go:multi .
运行:
docker run --rm -p 8080:8080 demo-go:multi
验证:
curl http://localhost:8080/health
这个版本做了什么优化
- 用
AS builder标记构建阶段 - 依赖下载和源码拷贝分开,提升缓存命中率
CGO_ENABLED=0编译静态二进制,减少运行时依赖-ldflags="-s -w"去掉调试符号,缩小二进制体积- 最终镜像使用更小的
alpine - 使用非 root 用户运行
第三步:进一步瘦身到极简运行时
如果你的 Go 程序不依赖 shell,也不需要在容器中调试,可以进一步使用 scratch。
FROM golang:1.21-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN apk add --no-cache ca-certificates && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /app/app .
FROM scratch
WORKDIR /app
COPY --from=builder /app/app /app/app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
EXPOSE 8080
ENTRYPOINT ["/app/app"]
构建:
docker build -t demo-go:scratch .
什么时候适合 scratch
适合:
- 静态编译产物
- 不需要 shell
- 不依赖动态链接库
- 希望极限瘦身
不太适合:
- 需要排查问题时进入容器调试
- 程序依赖系统库
- 某些语言运行时不能轻易静态化
我个人经验是:业务早期优先用 Alpine 或 Distroless,成熟后再考虑 scratch。 因为排障便利性也很重要。
构建提速:缓存优化的关键写法
很多人以为多阶段构建只解决“体积”,其实它对构建速度的影响同样非常大。
不推荐的写法
FROM golang:1.21-alpine AS builder
WORKDIR /src
COPY . .
RUN go mod download
RUN go build -o /app/app .
问题在于:
- 任意源码变动都会导致
COPY . .这一层失效 - 后面的
go mod download和go build基本都会重新执行
推荐的写法
FROM golang:1.21-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app/app .
这样只有依赖定义变更时才会重新下载模块。
使用 BuildKit 缓存挂载
更进一步,可以用 BuildKit 做依赖缓存:
# syntax=docker/dockerfile:1.4
FROM golang:1.21-alpine 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 \
go build -ldflags="-s -w" -o /app/app .
这个技巧在 CI 中很实用,尤其是模块较多时,效果会很明显。
构建流程时序图
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant Cache as 层缓存/BuildKit 缓存
participant Registry as 镜像仓库
Dev->>Docker: 提交源码并触发构建
Docker->>Cache: 检查 go.mod/go.sum 对应缓存
alt 依赖未变
Cache-->>Docker: 命中依赖层
else 依赖已变
Docker->>Docker: 重新下载依赖
end
Docker->>Docker: 编译二进制
Docker->>Docker: 生成最小运行时镜像
Docker->>Registry: 推送更小的最终镜像
安全/性能最佳实践
镜像瘦身和安全优化最好一起做,不要拆开看。
1. 选择更合适的基础镜像
常见选择:
alpine- 小,通用,调试相对方便
distroless- 更少组件,攻击面更小
scratch- 极简,但调试成本高
一个简单判断:
- 需要兼顾调试和体积:
alpine - 偏生产安全:
distroless - 极限瘦身:
scratch
2. 不要用 root 运行
很多镜像默认是 root,这在生产里并不理想。
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
如果你用的是 distroless,也可以用它自带的非 root 变体。
3. 用 .dockerignore 减少无效上下文
这个点经常被忽略,但收益很高。否则:
.git历史会被打包进构建上下文- 本地临时文件也会传给 Docker daemon
- 上下文越大,构建越慢
建议至少忽略:
.git
node_modules
dist
tmp
coverage
.env
尤其注意:不要把敏感文件误打进镜像上下文。
4. 固定基础镜像版本
不建议直接写:
FROM alpine:latest
建议固定小版本,甚至使用 digest:
FROM alpine:3.18
更严格一点:
FROM alpine@sha256:...
这样可以避免“今天能构建,明天突然炸”的漂移问题。
5. 尽量减少层和无效包
例如在 Alpine 中安装依赖时:
RUN apk add --no-cache ca-certificates
不要留下额外缓存。
如果是 Debian/Ubuntu 系镜像,记得及时清理:
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
6. 把漏洞扫描纳入流程
常见工具:
- Trivy
- Grype
- Docker Scout
例如使用 Trivy:
trivy image demo-go:multi
镜像越精简,通常扫描结果越干净,也更容易真正聚焦到需要修复的问题。
7. 尽量让容器“只做一件事”
不要把:
- 业务进程
- 定时任务
- 调试工具
- 迁移脚本
全部塞进同一个镜像里。镜像职责越单一,越好维护,也越容易瘦身。
常见坑与排查
这一部分很重要。多阶段构建本身不复杂,但实战中经常会遇到一些“明明写对了,却跑不起来”的问题。
坑 1:COPY --from=builder 路径写错
例如:
COPY --from=builder /app/app /app/app
但你的 builder 阶段实际输出到了 /src/app,就会报找不到文件。
排查方法:
- 检查
go build -o的输出路径 - 确保
COPY --from=...的源路径与构建阶段一致
坑 2:用了 scratch 后 HTTPS 请求失败
症状:
- 调用外部 HTTPS 接口时报证书错误
原因:
scratch里没有 CA 证书
解决:
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
坑 3:程序在 Alpine 能编译,在运行时崩溃
常见原因:
- 依赖了动态链接库
- CGO 相关配置不兼容
排查建议:
- 尝试静态编译:
RUN CGO_ENABLED=0 go build -o /app/app . - 如果必须 CGO:
- 评估是否继续使用 Alpine
- 或改成 Debian slim / distroless 对应版本
坑 4:缓存没命中,构建还是很慢
常见原因:
- 太早执行
COPY . . .dockerignore没配好- 依赖文件频繁变化
- CI 每次都是全新环境,未启用远程缓存
排查思路:
- 先看 Dockerfile 指令顺序
- 再看构建日志里哪一层总在重新执行
- 最后看 CI 是否支持 BuildKit cache
坑 5:切换非 root 用户后没有权限
症状:
- 启动时报文件不可读或目录不可写
解决方式:
RUN mkdir -p /app && chown -R appuser:appgroup /app
USER appuser
如果要写日志或临时文件,提前把目录权限处理好。
坑 6:镜像变小了,但构建时间反而更长
这不是不可能,常见于:
- builder 阶段做了过多额外处理
- 使用了过小基础镜像,安装依赖耗时更高
- 多架构构建引入额外开销
判断标准别只看镜像体积,还要综合看:
- 冷构建时间
- 热构建时间
- 镜像拉取时间
- 部署时间
- 排障成本
一套推荐的生产级 Dockerfile 模板
如果你现在要落地,我建议从下面这个模板开始,而不是一上来就追求极限 scratch。
# syntax=docker/dockerfile:1.4
FROM golang:1.21-alpine 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.18 AS runtime
RUN apk add --no-cache ca-certificates && \
addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /out/app /app/app
USER appuser
EXPOSE 8080
ENTRYPOINT ["/app/app"]
这个模板的平衡点比较好:
- 有缓存
- 镜像较小
- 有 CA 证书
- 非 root
- 排障成本比
scratch更低
逐步验证清单
你可以按下面这份清单验证自己的优化是否真的生效。
功能验证
docker build -t demo-go:prod .
docker run --rm -p 8080:8080 demo-go:prod
curl http://localhost:8080/health
体积对比
docker images | grep demo-go
对比:
demo-go:singledemo-go:multidemo-go:scratch
用户检查
进入容器查看当前用户(非 scratch 镜像):
docker run --rm demo-go:multi id
漏洞扫描
trivy image demo-go:multi
构建缓存验证
修改 main.go 某个响应字段后重新构建:
docker build -t demo-go:multi .
观察 go mod download 是否命中缓存。
方案取舍:别把“最小”误认为“最好”
做镜像治理时,我一般不建议把“体积最小”当作唯一目标。更准确的目标应该是:
- 满足运行需求的最小镜像
- 可接受的构建速度
- 可维护的排障成本
- 足够低的攻击面
一个简单对比:
| 方案 | 体积 | 调试便利 | 安全性 | 适用场景 |
|---|---|---|---|---|
| 单阶段构建 | 大 | 高 | 低 | 本地试验、临时验证 |
| 多阶段 + Alpine | 中小 | 中 | 中高 | 大多数业务服务 |
| 多阶段 + Distroless | 小 | 低 | 高 | 生产服务 |
| 多阶段 + Scratch | 极小 | 最低 | 很高 | 静态编译、成熟稳定服务 |
如果你的团队刚开始治理镜像,我建议路线是:
- 先从单阶段改为多阶段
- 再优化缓存与
.dockerignore - 然后落地非 root 与漏洞扫描
- 最后评估是否升级到 Distroless / Scratch
这个顺序更稳,也更容易在团队里推广。
总结
Docker 多阶段构建的价值,绝不只是“把镜像做小一点”。它本质上是在帮你完成一件更重要的事:
把构建环境和运行环境彻底分离,让镜像更轻、更快、更安全。
你可以把本文的核心建议记成 6 条:
- 构建与运行分阶段
- 依赖文件先复制,提升缓存命中
- 用
.dockerignore控制构建上下文 - 最终镜像只保留运行必需品
- 使用非 root 用户运行容器
- 把漏洞扫描和版本固定纳入流程
如果你现在就要落地,我建议最先做这三件事:
- 把现有单阶段 Dockerfile 改成多阶段
- 调整
COPY顺序,优先利用缓存 - 给运行时镜像加上非 root 用户和最小基础镜像
边界条件也要记住:
- 不是所有项目都适合
scratch - 镜像越小,不一定越容易维护
- 安全优化要结合调试和交付效率一起权衡
做得好的镜像,应该是团队愿意长期维护的镜像,而不是一次性“炫技”产物。多阶段构建,就是这条路上最值得优先投入的一步。