跳转到内容
123xiao | 无名键客

《Docker 多阶段构建与镜像瘦身实战:从构建提速到生产安全交付》

字数: 0 阅读时长: 1 分钟

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"]

这个写法的问题很常见:

  1. 构建环境和运行环境混在一起

    • golang:1.22 里有完整编译工具链
    • 运行时根本不需要这些东西
  2. 镜像层利用率差

    • COPY . . 太早,把所有文件都复制进去
    • 只改一行业务代码,也可能导致依赖层缓存失效
  3. 不必要的文件进入镜像

    • .git
    • 本地测试数据
    • 文档
    • 临时文件
    • 构建缓存
  4. 安全面扩大

    • 镜像越大,通常包含的包越多
    • 包越多,漏洞面越大
    • 以 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 builddocker 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=... 复制过去的文件

所以:

  • 编译器没了
  • 包管理器没了
  • 构建缓存没了
  • 中间文件没了

为什么它还能提速

提速主要来自两个方面:

  1. 层缓存更稳定

    • 先复制依赖描述文件,再安装依赖
    • 业务代码变更时,不一定重装全部依赖
  2. 构建产物更聚焦

    • 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:single
  • demo-go-app:multi
  • demo-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

常见原因

  1. 二进制依赖动态链接库,但运行镜像里没有
  2. 构建架构和运行架构不匹配
  3. 文件路径写错

排查思路

如果你用的是 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)尽量使用小而明确的基础镜像

优先级一般可以这样考虑:

  • distroless
  • alpine
  • debian:bookworm-slim
  • 不建议默认上来就用完整语言官方大镜像做运行时

但注意边界条件:

  • 需要 shell 调试:alpinedebian-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)不要把测试工具、调试工具带到生产镜像

很多镜像最后带着:

  • curl
  • vim
  • git
  • gcc
  • make

开发时很方便,生产时通常没必要。
这些工具会带来:

  • 更大的镜像
  • 更高的漏洞暴露面
  • 更复杂的合规扫描结果

建议保留单独的 debug 镜像,而不是污染正式运行镜像。


7)配合漏洞扫描,但不要迷信“0 漏洞”

生产安全交付里,镜像瘦身只是起点,不是终点。
你还应该配合:

  • 镜像漏洞扫描
  • 软件物料清单(SBOM)
  • 基础镜像定期升级
  • 最小权限运行
  • 只读根文件系统(如果业务允许)

边界条件也要清楚:

  • 镜像再小,也不等于绝对安全
  • 漏洞告警要结合是否可利用、是否暴露、是否在运行路径上来判断优先级

8)为 CI/CD 设计稳定缓存策略

在团队环境里,我建议这样做:

  • 依赖描述文件单独复制
  • 开启 BuildKit
  • 使用远程缓存或本地缓存目录
  • 尽量保持 Dockerfile 顺序稳定

一个经验规律是:

变化频率低的步骤放前面,变化频率高的步骤放后面。

例如:

  1. 基础镜像
  2. 系统依赖安装
  3. 依赖描述文件复制
  4. 依赖下载
  5. 源码复制
  6. 编译
  7. 运行配置

一个适合生产的参考模板

下面给一个更完整一点的模板,适合大多数 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 大复杂依赖应用

如果你正在带团队落地,我建议路线是:

  1. 先把单阶段改成多阶段
  2. 再补上 .dockerignore
  3. 再引入非 root 运行
  4. 最后再评估 distroless

这样推进阻力最小,也最容易看到收益。


总结

把 Docker 多阶段构建用好,带来的不只是“镜像小一点”这么简单,它实际上同时改善了三件事:

  • 构建效率:缓存更稳定,CI 更快
  • 交付效率:镜像更小,推送和拉取更快
  • 生产安全:运行环境更干净,攻击面更小

如果你只记住三个最实用的动作,我建议是:

  1. 把构建阶段和运行阶段拆开
  2. 先复制依赖描述文件,再复制源码
  3. 最终镜像只保留运行必需文件,并用非 root 运行

最后补一句很真实的经验:
镜像瘦身不是比赛,不必追求“最小值”,而要追求“可维护、可调试、可安全交付”。
能稳定上线、出问题能定位、团队能长期维护,这才是生产环境里真正有价值的优化。


分享到:

上一篇
《分布式架构中基于 Saga 模式的跨服务事务设计与落地实践-113》
下一篇
《自动化测试中的接口用例分层设计与稳定性优化实战》