Docker 多阶段构建与镜像瘦身实战:从构建提速到安全优化的完整方案
Docker 用久了,很多团队都会碰到同一类问题:镜像越来越大、构建越来越慢、漏洞扫描越来越红。刚开始一个服务几十 MB,看着很清爽;几个月后,镜像动不动几百 MB,CI 构建时间也被拖长,线上发布速度跟着受影响。
这篇文章我不打算只讲“什么是多阶段构建”,而是带你从一个常见的构建链路出发,一步步把镜像做小、把构建做快、把运行时环境做干净。重点不是概念,而是实战里真正能落地的方法。
背景与问题
先看一个典型场景:
- 业务是一个 Go / Node / Java 服务
- Dockerfile 里既安装编译工具,也把源码、缓存、测试文件一起打进镜像
- 构建阶段和运行阶段混在一起
- 最终镜像包含:
- gcc、make、git、curl
- 包管理器缓存
- 源码目录
- 测试文件
- 调试工具
- 结果是:
- 镜像体积大
- 拉取慢、推送慢
- 攻击面变大
- 漏洞数上升
- CI/CD 时间变长
很多人第一次写 Dockerfile 都是这种“能跑就行”的风格,比如:
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o app .
EXPOSE 8080
CMD ["./app"]
这个写法的问题很典型:
- 构建环境直接进入运行镜像
- 源码全部进入最终镜像
- 基础镜像偏大
- 缓存层设计不合理,改一行代码就全量重建
- 默认 root 用户运行,不够安全
如果你在公司里已经维护过几个服务,多半会发现:真正让 Docker 镜像“难受”的,不是单一问题,而是体积、速度、安全三件事常常绑在一起。
前置知识与环境准备
本文示例基于以下环境:
- Docker 20.10+
- 推荐启用 BuildKit
- 一个简单的 Go Web 服务示例
启用 BuildKit:
export DOCKER_BUILDKIT=1
或直接在构建时指定:
DOCKER_BUILDKIT=1 docker build -t demo-app .
示例目录结构:
demo-app/
├── Dockerfile
├── .dockerignore
├── go.mod
├── go.sum
└── main.go
示例 main.go:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello, multi-stage docker")
})
http.ListenAndServe(":8080", nil)
}
核心原理
1. 多阶段构建到底解决了什么
多阶段构建的核心思想很简单:
- 前一个阶段负责构建
- 最后一个阶段只保留运行必需品
也就是说,编译器、依赖缓存、源码、测试工具可以停留在 builder 阶段,而最终运行镜像只复制可执行文件或产物。
flowchart LR
A[源码] --> B[构建阶段 builder]
B --> C[编译产物]
C --> D[运行阶段 runtime]
D --> E[最终镜像更小更干净]
2. 镜像瘦身不只是“换个小底座”
很多人第一反应是把 ubuntu 换成 alpine,这当然有帮助,但不够。
镜像大小通常由几部分组成:
- 基础镜像大小
- 应用依赖大小
- 构建工具链大小
- 缓存与临时文件
- 被误打包进去的无关文件
所以真正有效的瘦身策略往往是组合拳:
- 多阶段构建
- 合理排序 Dockerfile 层
- 使用
.dockerignore - 选择合适的基础镜像
- 清理缓存和临时文件
- 非 root 运行
- 减少运行时依赖
3. 为什么它还能加速构建
多阶段构建本身不一定天然加速,但配合缓存设计后效果明显。
比如先复制 go.mod 和 go.sum 下载依赖,再复制业务代码:
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o app .
这样当你只修改业务代码时,go mod download 这一层就能复用缓存,不需要每次重新拉依赖。
4. 安全优化为什么和镜像瘦身是同一件事
镜像越大,通常意味着:
- 包含的软件越多
- 潜在漏洞越多
- 被利用的工具链越多
比如最终镜像里如果还带着 shell、curl、包管理器、编译器,攻击者一旦拿到容器内执行能力,利用空间就更大。
所以在很多场景下,更小的镜像 = 更小的攻击面。
一个从“能跑”到“可上线”的演进过程
我们先从一个不理想的版本开始,再逐步优化。
第 1 步:原始版本
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o app .
EXPOSE 8080
CMD ["./app"]
问题前面已经说过,这里不重复。
实战代码(可运行)
下面给出一个相对完整、可直接运行的版本。
方案一:标准多阶段构建(推荐起点)
# syntax=docker/dockerfile:1.6
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 /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"]
构建与运行
docker build -t demo-app:multi .
docker run --rm -p 8080:8080 demo-app:multi
访问测试:
curl http://127.0.0.1:8080
预期输出:
hello, multi-stage docker
这个版本相比原始写法,已经完成了几件关键事:
- 构建工具只留在
builder阶段 - 最终镜像只复制二进制文件
- 使用非 root 用户运行
-ldflags="-s -w"去掉调试符号,减小二进制体积
方案二:进一步瘦身,使用 distroless
如果你的程序是静态编译的 Go 服务,那么可以尝试 distroless 镜像。它比 Alpine 更偏“纯运行时”。
# syntax=docker/dockerfile:1.6
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 /out/app .
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /out/app /app/app
EXPOSE 8080
ENTRYPOINT ["/app/app"]
这个版本的特点:
- 没有 shell
- 没有包管理器
- 没有多余用户态工具
- 更适合生产运行环境
但边界条件也很明确:
- 你不能轻松
docker exec -it进去排查 - 某些依赖动态链接库的程序不适用
- 调试体验不如 Alpine 或 Debian slim
这类镜像我通常用于运行环境稳定、排错链路成熟的服务。
方案三:结合 BuildKit 缓存提速
如果你构建频繁,依赖下载是明显瓶颈,可以利用 BuildKit 的缓存挂载:
# syntax=docker/dockerfile:1.6
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"]
构建命令:
DOCKER_BUILDKIT=1 docker build -t demo-app:cache .
这个优化对于 CI 和本地开发都很实用。尤其是依赖较多时,效果会比较明显。
.dockerignore:一个很容易被忽略的瘦身点
很多镜像变大,不是 Dockerfile 多差,而是构建上下文太脏。
建议至少加入:
.git
.gitignore
Dockerfile
README.md
*.log
tmp/
dist/
node_modules/
coverage/
test/
tests/
一个简单但关键的事实是:只要文件进入构建上下文,就可能影响缓存和构建速度。
我见过有人把整个 .git 目录、历史制品甚至本地 IDE 配置一起送进 Docker 构建,白白浪费时间。
构建链路全貌
sequenceDiagram
participant Dev as 开发者
participant Docker as Docker/BuildKit
participant Builder as builder阶段
participant Runtime as runtime阶段
Dev->>Docker: docker build
Docker->>Builder: 复制 go.mod/go.sum
Builder->>Builder: 下载依赖并缓存
Docker->>Builder: 复制源码
Builder->>Builder: 编译产物 app
Docker->>Runtime: 仅复制 app
Runtime-->>Dev: 输出精简镜像
常见坑与排查
多阶段构建并不复杂,但真到项目里,坑还是挺集中。这里我挑最常见的说。
1. 构建成功,运行时报 no such file or directory
很常见,尤其是你从 golang 构建后拷到 alpine 或 distroless。
原因通常有:
- 二进制依赖动态链接库,但运行镜像里没有
- 架构不匹配
- 文件路径写错
- 可执行权限异常
排查方法:
docker run --rm -it --entrypoint sh demo-app:multi
进入后检查:
ls -l /app
如果是 Go 项目,优先尝试静态编译:
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app .
如果你必须启用 CGO,那就不能随便上 distroless/static,要选择更合适的运行时底座。
2. 明明用了多阶段,镜像还是很大
通常检查这几项:
- 最终阶段是否还执行了安装命令
- 是否复制了整个目录而不是产物
- 是否基础镜像选得太重
- 是否把缓存、日志、测试文件打进去了
错误示例:
FROM node:20 AS builder
WORKDIR /app
COPY . .
RUN npm install && npm run build
FROM nginx:latest
COPY --from=builder /app /usr/share/nginx/html
这里把整个 /app 复制过去了,可能连源码、node_modules、测试文件都进去了。
更合理的是只复制构建结果:
COPY --from=builder /app/dist /usr/share/nginx/html
3. 缓存总失效,构建还是慢
这是 Dockerfile 排序问题。
错误顺序:
COPY . .
RUN go mod download
RUN go build -o app .
因为源码一变,COPY . . 这一层就变,后面的依赖下载也会被迫重跑。
正确顺序:
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o app .
4. distroless 镜像里没法调试
这是设计使然,不是 bug。
如果你需要排障,可以采用两套镜像策略:
- 开发/排障镜像:基于
alpine或debian:slim - 生产镜像:基于
distroless
比如同一个 Dockerfile 里做双目标输出:
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .
FROM alpine:3.20 AS debug
WORKDIR /app
COPY --from=builder /out/app /app/app
RUN addgroup -S app && adduser -S app -G app
USER 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-app:debug .
docker build --target prod -t demo-app:prod .
这个方法我个人很常用,既保留调试便利,也不牺牲生产安全性。
5. 非 root 运行后权限报错
如果程序要写临时目录、日志目录,而这些目录归属还是 root,就会失败。
解决方法:
FROM alpine:3.20
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /out/app /app/app
RUN mkdir -p /app/data && chown -R app:app /app
USER app
如果是挂载卷,也要留意宿主机目录权限。
常见优化策略对比
flowchart TD
A[目标:更优镜像] --> B{主要痛点}
B -->|镜像太大| C[多阶段构建 + 更小基础镜像]
B -->|构建太慢| D[优化层顺序 + BuildKit缓存]
B -->|漏洞太多| E[distroless/nonroot + 减少依赖]
B -->|调试困难| F[debug/prod双镜像策略]
安全/性能最佳实践
这一节我尽量讲“有执行价值”的建议,而不是只列原则。
1. 运行阶段只保留必需品
最终镜像里应该尽量只有:
- 应用二进制或打包产物
- 必需运行时库
- 必要证书文件
- 必要配置目录
不要保留:
- 编译器
- 包管理器
- shell(视场景而定)
- 源码
- 测试工具
- 构建缓存
2. 优先使用明确版本,不要滥用 latest
不推荐:
FROM alpine:latest
推荐:
FROM alpine:3.20
原因很简单:
- 可复现
- 可回滚
- 避免上游突然变化导致构建结果漂移
如果条件允许,进一步用 digest 固定更稳:
FROM alpine:3.20@sha256:...
3. 使用非 root 用户运行
这是容器安全的基础动作,不算高阶技巧,但很值。
RUN addgroup -S app && adduser -S app -G app
USER app
即便容器内应用被突破,攻击者的初始权限也会更低。
4. 尽量减少层内垃圾文件
例如 Alpine 安装包后,如果确实需要包管理器安装软件,可以避免残留缓存:
RUN apk add --no-cache ca-certificates
Debian/Ubuntu 则注意:
RUN apt-get update && apt-get install -y ca-certificates \
&& rm -rf /var/lib/apt/lists/*
5. 做镜像扫描,但不要只停留在“看报告”
你可以用这些工具:
- Trivy
- Grype
- Docker Scout
示例:
trivy image demo-app:prod
但重点不是“扫了没”,而是看漏洞来源:
- 来自基础镜像?
- 来自系统包?
- 来自应用依赖?
- 来自不该存在的构建工具?
多阶段构建在这里的价值就很明显:它直接减少了无关依赖进入运行镜像的概率。
6. 把缓存优化和 CI 结合起来
如果你的 CI 经常从零开始构建,可以考虑:
- 开启 BuildKit
- 复用 registry cache
- 将依赖下载层尽量前置
- 不要频繁修改基础层
比如 GitHub Actions / GitLab CI 场景下,缓存命中率会直接影响流水线耗时。
7. 区分“调试便利”和“生产最优”
不要为了生产极简镜像,把排障能力全部砍掉;也不要为了图方便,把一堆调试工具带进生产。
比较稳妥的方式是:
- 开发环境:宽松、可调试
- 测试环境:接近生产
- 生产环境:极简、非 root、少依赖
逐步验证清单
如果你打算把现有项目改造成多阶段构建,可以按下面顺序做,不容易乱。
第一步:确认运行时真正需要什么
问自己三个问题:
- 最终镜像到底要运行什么文件?
- 这些文件依赖哪些动态库?
- 是否必须保留 shell 或诊断工具?
第二步:拆分 builder 和 runtime
最低限度做到:
- builder 负责编译
- runtime 只复制产物
第三步:重排 Dockerfile 层顺序
优先复制依赖描述文件,再复制源码。
第四步:补 .dockerignore
不要让无关文件进入构建上下文。
第五步:启用非 root 用户
同时检查目录权限、卷挂载权限。
第六步:扫描镜像并比较结果
建议记录优化前后的三个指标:
- 镜像大小
- 构建耗时
- 漏洞数量
只有比较,优化才有反馈闭环。
一个更完整的生产级示例
下面给一个更偏生产实践的 Go Dockerfile,包含缓存、非 root 和 distroless:
# syntax=docker/dockerfile:1.6
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 -trimpath -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"]
几个细节说明:
-trimpath:减少构建路径信息-ldflags="-s -w":剥离调试符号CGO_ENABLED=0:方便静态编译nonroot:默认非 root 运行- 不复制源码,不保留构建工具
什么时候不该盲目追求极限瘦身
这一点也很重要。
并不是所有项目都应该追求“最小镜像”。下面这些场景要谨慎:
1. 你需要频繁在线排障
如果业务复杂、排障链路不成熟,纯 distroless 可能让值班同学很痛苦。
这时可以先上 debian:slim 或保留 debug 版本。
2. 应用依赖动态库较多
例如部分 Python、Java、Node.js 原生模块,或者启用了 CGO 的 Go 程序,过度缩减底座可能会引入兼容性问题。
3. 你的构建瓶颈根本不在镜像
有些 CI 慢,是测试慢、网络慢、仓库慢,不一定是 Dockerfile 的锅。
要先测量,再优化。
所以更现实的原则是:在可维护性、可调试性和安全性之间找平衡。
总结
多阶段构建不是一个“高级技巧”,而应该算 Docker 时代的默认实践。它解决的不是单点问题,而是一条完整链路上的多个痛点:
- 构建阶段和运行阶段解耦
- 减少最终镜像体积
- 提升缓存命中率与构建速度
- 缩小攻击面
- 让镜像更适合生产发布
如果你现在手里有一个“能跑但很重”的 Dockerfile,我建议按这个顺序落地:
- 先拆成 builder/runtime 两阶段
- 调整
COPY顺序,确保依赖层可缓存 - 加上
.dockerignore - 使用非 root 用户
- 再考虑 Alpine / distroless 等更小底座
- 最后用扫描工具验证安全收益
最关键的一点是:别一上来就追求最小,而是追求“足够小、足够稳、足够好排障”。
这样做出来的镜像,才是真的适合长期维护。