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

《Docker 多阶段构建与镜像瘦身实战:从构建加速到生产环境安全发布》

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

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

这个写法的确简单,但它有几个明显问题:

  1. 构建环境直接进入运行环境
    golang:1.22 镜像里带有编译器、包管理能力、缓存、调试工具,这些对运行时并不必要。

  2. 镜像体积偏大
    最终生产镜像包含源码、构建缓存和工具链。

  3. 安全风险更高
    攻击者一旦进入容器,可利用的工具更多。

  4. 缓存利用不充分
    如果 COPY . . 放得太早,任何源码改动都会让依赖下载缓存失效。

我们真正想要的是:

  • 构建时有完整工具链
  • 运行时只保留产物和必要运行依赖
  • Docker 层缓存能更稳定命中
  • 镜像可复用、可审计、可安全发布

前置知识与环境准备

本文示例默认你已经具备这些环境:

  • Docker 20.10+
  • 推荐启用 BuildKit
  • 一台能联网拉取镜像的开发机
  • 基本了解 Dockerfile 常用指令:FROMCOPYRUNCMD

建议先开启 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)运行时镜像尽量最小化

建议优先级大致如下:

  1. distroless
  2. alpine
  3. 体积极致时用 scratch
  4. 避免在生产中直接用完整语言官方构建镜像作为运行时

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 多阶段构建最核心的价值,可以归纳成三句话:

  • 把构建环境和运行环境分开
  • 把缓存设计好,让重复构建更快
  • 把生产镜像尽量缩到只剩运行所需内容

如果你准备在项目里落地,我建议按这个顺序推进:

  1. 先把单阶段 Dockerfile 改成多阶段
  2. 调整 COPY 顺序,提升缓存命中率
  3. 增加 .dockerignore
  4. 运行镜像切换到 alpine 或 distroless
  5. 使用非 root 用户
  6. 在 CI 中加入镜像扫描和构建缓存

最后给一个实操建议:
第一次改造时,不要一上来就追求 scratch。先把结构理顺、缓存跑顺、安全基线补齐,收益就已经很明显。 等团队对排障和发布流程更熟,再继续向极致瘦身推进,会更稳。


分享到:

上一篇
《Java 中使用 CompletableFuture 构建高并发异步流程的实战指南》
下一篇
《分布式架构中基于一致性哈希与服务治理的缓存集群扩缩容实战》