Docker 多阶段构建与镜像瘦身实战:从构建提速到生产安全交付
很多团队刚开始用 Docker 时,都会经历一个“镜像越做越胖”的阶段:
- 一个 Go 服务,最后镜像居然几百 MB
- Node.js 项目把
node_modules、构建缓存、测试文件全打进去了 - Java 应用连 Maven、本地仓库、编译中间产物都在运行镜像里
- 线上镜像能跑是能跑,但拉取慢、发布慢、漏洞扫描一堆告警
我自己第一次接手这类镜像时,最直观的感受就是:构建慢、传输慢、部署慢,安全风险还更高。
而 Docker 多阶段构建(Multi-stage Build)就是解决这类问题的核心武器之一。
这篇文章不只讲概念,我会带你从“为什么镜像会胖”讲到“如何一步步改造成可用于生产交付的镜像”,并给出可运行的示例、排查方法和安全建议。
背景与问题
先看一个典型但不太理想的 Dockerfile。
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o app .
EXPOSE 8080
CMD ["./app"]
这个写法的问题很常见:
-
构建环境和运行环境混在一起
golang:1.22里有完整编译工具链- 运行时根本不需要这些东西
-
镜像层利用率差
COPY . .太早,把所有文件都复制进去- 只改一行业务代码,也可能导致依赖层缓存失效
-
不必要的文件进入镜像
.git- 本地测试数据
- 文档
- 临时文件
- 构建缓存
-
安全面扩大
- 镜像越大,通常包含的包越多
- 包越多,漏洞面越大
- 以 root 身份运行时,风险更高
镜像“胖”带来的实际影响
别小看镜像大小,它会直接影响交付效率:
- CI 构建时间变长
- 镜像推送到仓库更慢
- 节点拉取镜像更慢
- 滚动发布耗时更久
- 漏洞扫描结果更多,修复成本更高
可以把问题抽象成一条链路:
flowchart LR
A[代码提交] --> B[CI 构建]
B --> C[镜像推送]
C --> D[节点拉取]
D --> E[容器启动]
E --> F[生产交付]
G[镜像过大/层设计差] --> B
G --> C
G --> D
G --> F
前置知识与环境准备
本文示例基于以下环境,尽量保持通用:
- Docker 20.10+
- 推荐启用 BuildKit
- 任意 Linux / macOS 开发环境
- 会基本使用
docker build、docker run
建议先开启 BuildKit,很多缓存优化会更明显:
export DOCKER_BUILDKIT=1
也可以永久开启 Docker BuildKit,具体方式按你的 Docker 环境配置即可。
核心原理
多阶段构建的核心思想并不复杂:
把“编译/打包”和“运行”拆成多个阶段,最后只把运行真正需要的产物复制到最终镜像中。
一个直观理解
构建阶段像“厨房”:
- 有刀具
- 有锅
- 有原材料
- 有各种调料
运行阶段像“上桌”:
- 只需要端上成品
- 不需要把锅碗瓢盆一起端给用户
在 Dockerfile 里,这通常表现为:
- 第一阶段:安装依赖、编译代码
- 第二阶段:拷贝编译产物,作为最终运行镜像
多阶段构建的基本结构
FROM builder-image AS builder
# 编译、打包
FROM runtime-image
# 只复制最终产物
COPY --from=builder /path/to/bin /app/bin
为什么它能瘦身
因为最终镜像不会继承前一个阶段的全部内容,只会保留:
- 最后一个
FROM指定的基础镜像 - 你显式
COPY --from=...复制过去的文件
所以:
- 编译器没了
- 包管理器没了
- 构建缓存没了
- 中间文件没了
为什么它还能提速
提速主要来自两个方面:
-
层缓存更稳定
- 先复制依赖描述文件,再安装依赖
- 业务代码变更时,不一定重装全部依赖
-
构建产物更聚焦
- CI 推送的最终镜像更小
- 网络传输时间更短
下面这张图可以把流程看清楚:
flowchart TD
A[源码目录] --> B[构建阶段 builder]
B --> B1[安装依赖]
B --> B2[编译/打包]
B2 --> C[产物]
C --> D[运行阶段 runtime]
D --> E[仅包含运行时必要文件]
E --> F[推送到镜像仓库]
F --> G[生产环境拉取运行]
实战代码(可运行)
下面我用一个 Go Web 服务做示例。Go 很适合演示多阶段构建,因为最终二进制可以非常精简。
示例项目结构
demo-go-app/
├── Dockerfile
├── .dockerignore
├── go.mod
├── go.sum
└── main.go
1)准备一个最小可运行程序
main.go
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello, multi-stage docker build\n")
})
log.Printf("server listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
go.mod
module demo-go-app
go 1.22
2)先看一个“单阶段”版本
Dockerfile.single
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app .
EXPOSE 8080
CMD ["./app"]
构建:
docker build -f Dockerfile.single -t demo-go-app:single .
查看镜像大小:
docker images | grep demo-go-app
你大概率会看到这个镜像并不小,因为它带上了完整 Go 工具链。
3)改造成多阶段构建
Dockerfile
# syntax=docker/dockerfile:1
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 -ldflags="-s -w" -o /app/server .
# 使用更小的运行时镜像
FROM alpine:3.20
WORKDIR /app
# 创建非 root 用户
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder /app/server /app/server
USER app
EXPOSE 8080
ENTRYPOINT ["/app/server"]
构建:
docker build -t demo-go-app:multi .
运行:
docker run --rm -p 8080:8080 demo-go-app:multi
访问:
curl http://localhost:8080
输出应类似:
hello, multi-stage docker build
4)配合 .dockerignore 再瘦一层
很多人只写 Dockerfile,不写 .dockerignore,这是我见过最常见的“漏优化点”之一。
.dockerignore
.git
.gitignore
Dockerfile.single
README.md
tmp/
dist/
coverage/
*.log
它的作用是:减少传给 Docker 构建上下文的文件。
这会带来两个直接收益:
- 构建上下文更小,上传更快
- 避免无关文件变动导致缓存失效
构建时你会看到类似输出:
Sending build context to Docker daemon 12.8kB
如果没有 .dockerignore,这个数字可能大得多。
5)进一步升级:使用 distroless 作为运行时镜像
如果你的程序是静态编译的,且不依赖 shell 等运行时工具,可以进一步缩小和加固镜像。
Dockerfile.distroless
# syntax=docker/dockerfile:1
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 -ldflags="-s -w" -o /server .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
构建:
docker build -f Dockerfile.distroless -t demo-go-app:distroless .
运行:
docker run --rm -p 8080:8080 demo-go-app:distroless
什么时候选 alpine,什么时候选 distroless?
-
alpine
- 体积小
- 有基础包管理能力
- 调试稍方便
- 适合很多通用场景
-
distroless
- 更极致地减少无关组件
- 没有 shell,攻击面更小
- 更适合生产运行镜像
- 调试不方便,需要外部手段排查
如果你问我的建议:
- 开发/联调阶段:
alpine - 稳定生产阶段:
distroless
6)构建缓存优化:让 CI 更快
在中大型项目里,真正耗时的常常不是 Docker 本身,而是依赖下载。
如果用 BuildKit,可以把缓存做得更细。
带缓存挂载的 Go 构建
# 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 /app/server .
FROM alpine:3.20
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder /app/server /app/server
USER app
ENTRYPOINT ["/app/server"]
这类写法在 CI 重复构建时通常很有价值。
逐步验证清单
建议你每做一步,就验证一步,不要一次性改完然后“祈祷能跑”。
验证 1:镜像是否变小
docker images | grep demo-go-app
对比:
demo-go-app:singledemo-go-app:multidemo-go-app:distroless
验证 2:容器是否正常启动
docker run --rm -p 8080:8080 demo-go-app:multi
验证 3:接口是否正常返回
curl http://localhost:8080
验证 4:是否以非 root 运行
docker run --rm demo-go-app:multi id
如果你的镜像没有 id 命令,可以进入 alpine 类镜像测试,或者通过容器编排平台查看用户配置。
distroless 通常没有 shell 和工具,这一点要提前知道。
验证 5:检查镜像层
docker history demo-go-app:multi
这个命令很实用,能帮助你判断:
- 哪一层最大
- 哪一步引入了无关内容
- 是否把编译工具链带进了最终镜像
常见坑与排查
这一节我尽量讲一些“真正会踩”的坑,而不是只列概念。
1)COPY . . 太早,缓存形同虚设
现象
代码稍微一改,依赖重新下载,构建非常慢。
原因
你先 COPY . .,导致所有源代码变动都会影响后续层缓存。
错误写法
COPY . .
RUN go mod download
推荐写法
COPY go.mod go.sum ./
RUN go mod download
COPY . .
2)最终镜像启动报错:no such file or directory
现象
二进制明明复制进去了,但运行时报:
exec /app/server: no such file or directory
常见原因
- 二进制依赖动态链接库,但运行镜像里没有
- 构建架构和运行架构不匹配
- 文件路径写错
排查思路
如果你用的是 Alpine,先进入容器看文件:
docker run --rm -it --entrypoint sh demo-go-app:multi
查看:
ls -l /app
对于 Go,优先尝试静态编译:
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /app/server .
如果是 C/C++ 依赖较多的程序,不能简单套这个方案,需要明确动态库依赖。
3)Alpine 很小,但并不总是“无脑更优”
现象
切到 Alpine 后程序运行异常,尤其是涉及:
- 字体
- 时区
- SSL 证书
- glibc 兼容
原因
Alpine 基于 musl libc,不是所有程序都与之天然兼容。
处理建议
- 如果程序强依赖 glibc,优先考虑 Debian slim
- 如果是静态编译二进制,Alpine 或 distroless 通常更合适
- 涉及 CA 证书时,确认镜像中是否包含证书链
4)distroless 镜像不好排查
现象
线上容器启动失败,但你进不去,因为没有 shell。
这不是 bug,而是特性
distroless 的目标就是最小化运行时环境。
解决办法
- 开发阶段保留一个调试版镜像
- 生产用 distroless,问题复现用 alpine/debug 镜像
- 用日志、健康检查、探针和外部诊断工具代替容器内手工排查
我自己的经验是:
不要在所有环境都强推 distroless。开发、测试、生产镜像可以分层次设计,而不是一把梭。
5)忘记 .dockerignore,把敏感文件打进镜像
风险点
如果你把下面这类文件带进构建上下文,后果可能很麻烦:
.env- 私钥文件
- 本地配置
- 测试账号数据
即便最终层未必保留,它们也可能进入构建过程或镜像历史。
建议
显式维护 .dockerignore:
.env
*.pem
*.key
.secrets/
.git
安全/性能最佳实践
这一节是“从能用到适合生产”的关键。
1)尽量使用小而明确的基础镜像
优先级一般可以这样考虑:
distrolessalpinedebian:bookworm-slim- 不建议默认上来就用完整语言官方大镜像做运行时
但注意边界条件:
- 需要 shell 调试:
alpine或debian-slim - 需要更好兼容性:
debian-slim - 纯静态二进制:
distroless很适合
2)构建镜像和运行镜像分离
这是多阶段构建最核心的实践准则:
- 构建阶段:工具尽量齐全
- 运行阶段:只放应用和最小依赖
可以把它理解为“供应链隔离”的第一步。
sequenceDiagram
participant Dev as 开发者
participant CI as CI构建机
participant Builder as builder阶段
participant Runtime as runtime镜像
participant Prod as 生产环境
Dev->>CI: 提交代码
CI->>Builder: 下载依赖/编译/测试
Builder->>Runtime: 复制最终产物
Runtime->>Prod: 推送并部署
Note over Runtime,Prod: 不包含编译器、缓存、源码
3)使用非 root 用户运行
很多镜像默认 root 运行,这在生产里不是好习惯。
示例
RUN addgroup -S app && adduser -S app -G app
USER app
这样即便应用被利用,权限边界也相对更小。
4)固定基础镜像版本,不要只写 latest
不推荐
FROM alpine:latest
更推荐
FROM alpine:3.20
更进一步,可以固定 digest:
FROM alpine:3.20@sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
这样能提升构建可重复性,避免“昨天能构建,今天突然不行”。
5)减少镜像层中的无用内容
比如安装包后及时清理缓存。
以 Alpine 为例:
RUN apk add --no-cache ca-certificates
而不是:
RUN apk add ca-certificates
在 Debian/Ubuntu 类镜像里,也应注意清理 apt 缓存。
6)不要把测试工具、调试工具带到生产镜像
很多镜像最后带着:
curlvimgitgccmake
开发时很方便,生产时通常没必要。
这些工具会带来:
- 更大的镜像
- 更高的漏洞暴露面
- 更复杂的合规扫描结果
建议保留单独的 debug 镜像,而不是污染正式运行镜像。
7)配合漏洞扫描,但不要迷信“0 漏洞”
生产安全交付里,镜像瘦身只是起点,不是终点。
你还应该配合:
- 镜像漏洞扫描
- 软件物料清单(SBOM)
- 基础镜像定期升级
- 最小权限运行
- 只读根文件系统(如果业务允许)
边界条件也要清楚:
- 镜像再小,也不等于绝对安全
- 漏洞告警要结合是否可利用、是否暴露、是否在运行路径上来判断优先级
8)为 CI/CD 设计稳定缓存策略
在团队环境里,我建议这样做:
- 依赖描述文件单独复制
- 开启 BuildKit
- 使用远程缓存或本地缓存目录
- 尽量保持 Dockerfile 顺序稳定
一个经验规律是:
变化频率低的步骤放前面,变化频率高的步骤放后面。
例如:
- 基础镜像
- 系统依赖安装
- 依赖描述文件复制
- 依赖下载
- 源码复制
- 编译
- 运行配置
一个适合生产的参考模板
下面给一个更完整一点的模板,适合大多数 Go Web 服务参考。
# 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/server .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
如果你需要证书、时区等额外运行资源,就按需复制或选择更合适的基础镜像,不要盲目追求“最小到极致”。
方案取舍:不是越小越好,而是越合适越好
这里给一个简短的选型建议表。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单阶段构建 | 简单直接 | 镜像大、安全面大 | 本地临时验证 |
| 多阶段 + Alpine | 小、通用、较易调试 | 兼容性需关注 | 多数中小型服务 |
| 多阶段 + Distroless | 更小、更安全 | 调试困难 | 稳定的生产服务 |
| 多阶段 + Debian Slim | 兼容性更强 | 比 Alpine 大 | 复杂依赖应用 |
如果你正在带团队落地,我建议路线是:
- 先把单阶段改成多阶段
- 再补上
.dockerignore - 再引入非 root 运行
- 最后再评估 distroless
这样推进阻力最小,也最容易看到收益。
总结
把 Docker 多阶段构建用好,带来的不只是“镜像小一点”这么简单,它实际上同时改善了三件事:
- 构建效率:缓存更稳定,CI 更快
- 交付效率:镜像更小,推送和拉取更快
- 生产安全:运行环境更干净,攻击面更小
如果你只记住三个最实用的动作,我建议是:
- 把构建阶段和运行阶段拆开
- 先复制依赖描述文件,再复制源码
- 最终镜像只保留运行必需文件,并用非 root 运行
最后补一句很真实的经验:
镜像瘦身不是比赛,不必追求“最小值”,而要追求“可维护、可调试、可安全交付”。
能稳定上线、出问题能定位、团队能长期维护,这才是生产环境里真正有价值的优化。