Docker 多阶段构建与镜像瘦身实战:从构建加速到生产环境安全发布
很多团队在刚用 Docker 时,往往先追求“能跑起来”。结果跑着跑着,就会遇到几个很现实的问题:
- 镜像越来越大,动不动几百 MB 甚至上 GB
- CI 构建越来越慢,推送和拉取时间明显拉长
- 生产环境镜像里混入编译工具、包管理器、调试命令,攻击面变大
- 同一个 Dockerfile 既要构建又要发布,层次混乱,维护成本高
我自己第一次认真治理镜像体积时,最直观的感受就是:镜像瘦身不只是省磁盘,它会连带改善构建速度、交付效率和安全性。而 Docker 多阶段构建,就是这个问题里最值得掌握的一把“瑞士军刀”。
本文会从原理讲到实战,带你做一个完整的多阶段构建示例,并且把常见坑、排查方法和安全发布建议一起梳理清楚。
背景与问题
先看一个常见但不太理想的 Dockerfile 写法。以 Go 项目为例:
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o server .
EXPOSE 8080
CMD ["./server"]
这个写法的确简单,但它有几个明显问题:
-
构建环境直接进入运行环境
golang:1.22镜像里带有编译器、包管理能力、缓存、调试工具,这些对运行时并不必要。 -
镜像体积偏大
最终生产镜像包含源码、构建缓存和工具链。 -
安全风险更高
攻击者一旦进入容器,可利用的工具更多。 -
缓存利用不充分
如果COPY . .放得太早,任何源码改动都会让依赖下载缓存失效。
我们真正想要的是:
- 构建时有完整工具链
- 运行时只保留产物和必要运行依赖
- Docker 层缓存能更稳定命中
- 镜像可复用、可审计、可安全发布
前置知识与环境准备
本文示例默认你已经具备这些环境:
- Docker 20.10+
- 推荐启用 BuildKit
- 一台能联网拉取镜像的开发机
- 基本了解 Dockerfile 常用指令:
FROM、COPY、RUN、CMD
建议先开启 BuildKit:
export DOCKER_BUILDKIT=1
如果你使用的是较新的 Docker Desktop,通常已经默认启用。
核心原理
多阶段构建的核心思想其实很朴素:
一个 Dockerfile 里定义多个构建阶段,每个阶段只负责一件事,最后只把“需要的结果”复制到最终镜像。
比如:
- 阶段 1:下载依赖
- 阶段 2:编译程序
- 阶段 3:组装最小运行镜像
为什么它能瘦身
因为最终镜像只保留:
- 可执行文件
- 配置文件
- 必要证书或运行库
而不会带上:
- 构建工具链
- 临时文件
- 包管理器缓存
- 源代码(视场景而定)
为什么它能加速
因为 Docker 是按层缓存的。如果 Dockerfile 写得好:
- 依赖下载层稳定
- 源码变更只触发后续层重建
- CI 中重复构建能更快命中缓存
一个流程图看懂多阶段构建
flowchart LR
A[源码与依赖清单] --> B[builder 阶段<br/>下载依赖并编译]
B --> C[生成二进制产物]
C --> D[runner 阶段<br/>仅复制产物]
D --> E[生产镜像<br/>更小 更安全]
构建与运行职责分离
flowchart TD
A[构建阶段] --> A1[安装编译工具]
A --> A2[拉取依赖]
A --> A3[编译打包]
B[运行阶段] --> B1[仅保留应用]
B --> B2[最小依赖]
B --> B3[非 root 启动]
A3 --> B1
实战代码(可运行)
下面我们用一个可运行的 Go Web 服务做例子。你可以直接照着建一个目录测试。
目录结构
docker-multi-stage-demo/
├── Dockerfile
├── .dockerignore
├── go.mod
└── 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 from multi-stage docker build\n")
})
log.Printf("server listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
2)依赖文件
go.mod
module docker-multi-stage-demo
go 1.22
3)先看一个“普通版”Dockerfile
这是很多人一开始会写的版本:
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o server .
EXPOSE 8080
CMD ["./server"]
能用,但不够好。
4)改造成多阶段构建版本
Dockerfile
# syntax=docker/dockerfile:1.6
FROM golang:1.22-alpine AS builder
WORKDIR /src
# 先复制依赖描述文件,尽量命中缓存
COPY go.mod ./
# 使用 BuildKit 缓存 go 模块下载
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 alpine:3.20 AS runner
WORKDIR /app
# 安装运行时必要包,例如 CA 证书
RUN apk add --no-cache ca-certificates && \
addgroup -S app && adduser -S app -G app
COPY --from=builder /out/server /app/server
USER app
EXPOSE 8080
ENTRYPOINT ["/app/server"]
这个版本已经体现了几个关键点:
builder阶段负责编译runner阶段只负责运行- 通过
COPY go.mod ./提前下载依赖,提高缓存命中 - 用
--mount=type=cache缓存 Go 构建产物 - 使用
-ldflags="-s -w"去掉符号信息,进一步减小二进制体积 - 使用非 root 用户运行服务
5).dockerignore 不能省
很多镜像变大,不只是 Dockerfile 的问题,而是把不该复制的内容全带进上下文了。
.dockerignore
.git
.gitignore
Dockerfile
README.md
tmp
dist
node_modules
*.log
如果没有这个文件,构建上下文可能会把 .git、日志文件、测试产物一起送到 Docker daemon。轻则构建慢,重则缓存经常失效。
6)构建镜像
docker build -t demo-multi-stage:1.0 .
7)运行容器
docker run --rm -p 8080:8080 demo-multi-stage:1.0
访问:
curl http://127.0.0.1:8080
输出类似:
hello from multi-stage docker build
逐步验证清单
做教程类实践时,我建议不要只看“构建成功”,而是逐步验证。
验证 1:镜像大小变化
查看镜像体积:
docker images | grep demo-multi-stage
你通常会发现多阶段版本相比单阶段明显变小。
验证 2:镜像内容是否干净
进入容器测试:
docker run --rm -it demo-multi-stage:1.0 sh
你会发现运行镜像里没有 Go 编译工具链,这就是我们想要的结果。
验证 3:容器用户不是 root
docker run --rm demo-multi-stage:1.0 id
输出应类似:
uid=100(app) gid=101(app) groups=101(app)
验证 4:重复构建是否更快
连续构建两次:
docker build -t demo-multi-stage:1.0 .
docker build -t demo-multi-stage:1.0 .
第二次如果依赖没变化,通常会更快,因为缓存已命中。
多阶段构建中的缓存设计
很多人知道多阶段构建,但不会“设计缓存层”。这一步决定了构建速度上限。
一个常见误区
错误顺序:
COPY . .
RUN go mod download
RUN go build -o server .
问题是:只要源码有一点点改动,COPY . . 这一层就变化了,后续 go mod download 也得重跑。
更合理的顺序
COPY go.mod ./
RUN go mod download
COPY . .
RUN go build -o server .
如果依赖没变,下载层能直接复用。
缓存命中过程
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker Build
participant Cache as 层缓存
Dev->>Docker: 提交源码改动
Docker->>Cache: 检查 go.mod 层
Cache-->>Docker: 命中
Docker->>Cache: 检查源码 COPY 层
Cache-->>Docker: 失效
Docker->>Docker: 重新编译应用
Docker-->>Dev: 输出新镜像
进阶:生产环境更小的运行镜像
如果你的程序是静态编译的,还可以进一步压缩运行镜像,比如用 scratch。
基于 scratch 的版本
# syntax=docker/dockerfile:1.6
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod ./
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 scratch
COPY --from=builder /out/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
scratch 的边界条件
这类镜像非常小,但不是所有服务都适合:
- 没有 shell,排障不方便
- 没有证书文件时,访问 HTTPS 可能失败
- 如果程序依赖动态库,可能无法运行
所以我的建议是:
- 极致追求体积且程序足够简单:可以试
scratch - 更通用、更好排障:优先选
alpine或 distroless
常见坑与排查
这一部分很重要。我把自己和团队里最常遇到的坑列出来。
1)容器能构建,运行时报 “no such file or directory”
典型现象:
exec /app/server: no such file or directory
这不一定是文件不存在,常见原因是:
- 二进制依赖动态库,但运行镜像没有
- 架构不匹配,比如构建成了
arm64,运行在amd64 - 可执行权限异常
排查方法:
docker run --rm -it <image> sh
ls -l /app
file /app/server
如果你用的是静态 Go 二进制,确保:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64
2)HTTPS 请求失败,提示证书错误
现象:
x509: certificate signed by unknown authority
原因通常是运行镜像没有 CA 证书。
解决方式:
RUN apk add --no-cache ca-certificates
如果是 scratch,需要从 builder 或其他镜像里复制证书文件。
3)缓存不命中,构建总是很慢
重点检查:
- 是否
COPY . .太早 - 是否
.dockerignore缺失 - 是否依赖文件和源码文件混在一起导致频繁失效
- CI 是否启用了 BuildKit 和远程缓存
4)运行镜像里仍然带着源码
常见原因:
- 在最终阶段又执行了
COPY . . - 误把构建目录整体复制进 runner
正确做法是只复制构建产物:
COPY --from=builder /out/server /app/server
5)多阶段名称写错
比如:
COPY --from=build /out/server /app/server
但你真正定义的是:
FROM golang:1.22-alpine AS builder
这类错误很常见,尤其是 Dockerfile 变长之后。
安全/性能最佳实践
多阶段构建不是目的,它是手段。最终目标是交付更稳、更快、更安全的生产镜像。
1)运行时镜像尽量最小化
建议优先级大致如下:
- distroless
- alpine
- 体积极致时用 scratch
- 避免在生产中直接用完整语言官方构建镜像作为运行时
2)使用非 root 用户
不要图省事默认 root 运行。即便业务容器被入侵,权限边界也会更清晰。
RUN addgroup -S app && adduser -S app -G app
USER app
3)固定基础镜像版本
不要直接写:
FROM alpine:latest
推荐固定版本,甚至固定 digest。这样更可控,也利于审计和回滚。
4)减少无用层和缓存残留
例如在 Alpine 中安装包时使用:
RUN apk add --no-cache ca-certificates
避免留下额外缓存。
5)构建时注入,运行时最小暴露
像 Git 凭证、私有依赖认证信息,不要写死进镜像。可以通过构建 secret、CI 环境变量等方式临时注入。
6)配合漏洞扫描
多阶段构建减少了攻击面,但不等于天然安全。建议在 CI 中增加镜像扫描,例如:
- Trivy
- Grype
- Docker Scout
7)合理拆分构建阶段
对于前后端混合项目,可以这样拆:
deps:安装依赖build:编译test:执行测试runner:生产运行
这样结构更清晰,也便于复用某个中间阶段做调试。
8)按需保留调试能力
生产镜像最好保持最小,但并不意味着所有场景都用“极简镜像”。
如果你的团队排障经验还不足,直接上 scratch 可能会把问题转移到线上定位成本上。这个边界要把握好。
一个更完整的多阶段示例:带测试阶段
如果你想把测试也纳入 Docker 构建流程,可以这样组织:
# syntax=docker/dockerfile:1.6
FROM golang:1.22-alpine AS deps
WORKDIR /src
COPY go.mod ./
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 build
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/server .
FROM alpine:3.20 AS runner
WORKDIR /app
RUN apk add --no-cache ca-certificates && \
addgroup -S app && adduser -S app -G app
COPY --from=build /out/server /app/server
USER app
EXPOSE 8080
ENTRYPOINT ["/app/server"]
这样带来的好处是:
- 测试失败时,不会继续产出发布镜像
deps阶段可复用,构建更稳- CI 流程更接近真实生产交付过程
什么时候不必“极限瘦身”
这是一个很实际的问题。不是所有项目都要把镜像抠到极致。
以下场景可以适度保守:
- 内网服务,镜像拉取频率不高
- 团队以排障效率优先
- 服务依赖复杂系统库,迁移到极简镜像成本高
- 构建耗时主要不在镜像层,而在测试或外部依赖
换句话说,镜像瘦身要服务于整体交付效率,而不是为了“数字好看”。
总结
Docker 多阶段构建最核心的价值,可以归纳成三句话:
- 把构建环境和运行环境分开
- 把缓存设计好,让重复构建更快
- 把生产镜像尽量缩到只剩运行所需内容
如果你准备在项目里落地,我建议按这个顺序推进:
- 先把单阶段 Dockerfile 改成多阶段
- 调整
COPY顺序,提升缓存命中率 - 增加
.dockerignore - 运行镜像切换到
alpine或 distroless - 使用非 root 用户
- 在 CI 中加入镜像扫描和构建缓存
最后给一个实操建议:
第一次改造时,不要一上来就追求 scratch。先把结构理顺、缓存跑顺、安全基线补齐,收益就已经很明显。 等团队对排障和发布流程更熟,再继续向极致瘦身推进,会更稳。