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

《Docker 多阶段构建与镜像瘦身实战:从构建提速到安全交付的完整优化方案》

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

Docker 多阶段构建与镜像瘦身实战:从构建提速到安全交付的完整优化方案

很多团队刚开始用 Docker 时,最容易犯的一个错误就是:“先把应用跑起来再说”。结果往往是镜像能用,但很重、构建慢、漏洞多,上线后排查问题还费劲。

我自己早期也踩过这个坑。一个很普通的 Java 服务,构建镜像时把 Maven、源码、测试文件、缓存、调试工具全塞进去了,最后镜像接近 1GB。每次 CI 推送像在搬家,安全扫描一跑全是高危依赖,发布效率和交付质量都被拖住了。

这篇文章我不只讲“什么是多阶段构建”,而是带你从一个真实的优化路径走一遍:

  • 为什么镜像会又大又慢
  • 多阶段构建到底解决了什么
  • 怎么写出可运行的 Dockerfile
  • 怎么结合缓存提速
  • 怎么做安全交付
  • 常见坑怎么查

如果你已经会写基础 Dockerfile,但还没有系统整理过构建链路,这篇会比较适合你。


背景与问题

在实际项目里,Docker 镜像膨胀通常来自几个典型原因:

  1. 构建工具直接进入运行环境

    • 比如 Go 镜像里保留了编译器
    • Java 镜像里保留了 Maven 和本地仓库
    • Node.js 镜像里留着构建依赖和源代码
  2. 把整个项目目录无脑 COPY . .

    • .git
    • 本地日志
    • 测试数据
    • node_modules
    • 编译产物
  3. 不会利用层缓存

    • 依赖文件和业务代码混在一起复制
    • 任意一个文件变化,就导致依赖重装
  4. 基础镜像选型不合理

    • 直接用 ubuntucentos 作为运行镜像
    • 运行时带了很多根本用不上的系统工具
  5. 安全边界意识不足

    • 容器里默认 root
    • 镜像里包含密钥或私有配置
    • 缺少漏洞扫描和最小权限控制

这些问题的直接后果通常有三个:

  • 构建慢:CI/CD 时间变长
  • 传输慢:镜像拉取和发布耗时高
  • 风险高:攻击面扩大,漏洞数量上升

前置知识与环境准备

建议你先具备这些基础:

  • 会写基础 Dockerfile
  • 知道镜像分层、容器运行的基本概念
  • 本地已安装 Docker 20.10+
  • 最好启用 BuildKit

可以先确认环境:

docker version
docker buildx version

启用 BuildKit:

export DOCKER_BUILDKIT=1

核心原理

多阶段构建的关键思想其实很朴素:

把“构建环境”和“运行环境”彻底分开。

比如一个 Go 应用,构建阶段需要:

  • Go 编译器
  • Git
  • 构建缓存

但运行阶段只需要:

  • 一个可执行二进制
  • 少量运行时依赖
  • 非 root 用户

多阶段构建允许我们在一个 Dockerfile 里定义多个阶段:

  • 第一阶段:编译
  • 第二阶段:测试
  • 第三阶段:运行

最后只把需要的产物复制进最终镜像。

一个直观对比

flowchart LR
    A[源码] --> B[传统单阶段构建]
    B --> C[包含编译器/缓存/源码/产物]
    C --> D[镜像大 构建慢 风险高]

    A --> E[多阶段构建]
    E --> F[构建阶段 负责编译]
    F --> G[运行阶段 仅复制产物]
    G --> H[镜像小 启动快 风险低]

Docker 分层缓存为什么重要

Docker 每条指令都会形成一层。只要某一层发生变化,后续层通常都要重新构建。

所以 Dockerfile 的顺序很重要:

  • 稳定的内容放前面
  • 经常变化的内容放后面

例如 Node.js 项目中,应先复制 package.json 和 lock 文件安装依赖,再复制源码。这样只改业务代码时,不会每次都重新安装依赖。

多阶段构建的几个核心收益

  1. 镜像瘦身
  2. 减少攻击面
  3. 更清晰的构建链路
  4. 便于测试、扫描和发布分离
  5. 更好地利用缓存

优化思路全景图

在实战里,我一般不是只做“多阶段构建”这一件事,而是把它放进一套完整流程中。

flowchart TD
    A[源码提交] --> B[依赖解析与缓存]
    B --> C[编译/测试]
    C --> D[多阶段构建]
    D --> E[生成最小运行镜像]
    E --> F[漏洞扫描]
    F --> G[签名/推送镜像]
    G --> H[部署到环境]

这张图想表达的是:镜像瘦身不是终点,安全交付才是终点


实战代码:一步步把镜像做小、做快、做安全

下面我用一个 Go Web 服务 做演示。因为 Go 的优化效果直观,代码也容易跑起来。

项目结构

demo-go-app/
├── Dockerfile
├── .dockerignore
├── go.mod
├── go.sum
└── main.go

示例应用代码

main.go

package main

import (
	"fmt"
	"net/http"
	"os"
)

func handler(w http.ResponseWriter, r *http.Request) {
	hostname, _ := os.Hostname()
	fmt.Fprintf(w, "hello from %s\n", hostname)
}

func main() {
	http.HandleFunc("/", handler)
	fmt.Println("server started at :8080")
	http.ListenAndServe(":8080", nil)
}

go.mod

module demo-go-app

go 1.22

先看一个不推荐的单阶段 Dockerfile

FROM golang:1.22

WORKDIR /app
COPY . .
RUN go build -o app .

EXPOSE 8080
CMD ["./app"]

这个版本的问题很典型:

  • 最终镜像里带有完整 Go 工具链
  • 源码也在镜像里
  • 镜像体积偏大
  • 安全扫描时会扫出更多运行时无关组件

改造成多阶段构建

第一版:最基础可用版

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 -o /app/app .

FROM alpine:3.20

WORKDIR /app
COPY --from=builder /app/app /app/app

EXPOSE 8080
CMD ["/app/app"]

这个版本已经完成了最核心的优化:

  • 构建工具只存在于 builder 阶段
  • 最终镜像只保留二进制
  • 镜像体积通常会明显下降

构建并运行

docker build -t demo-go-app:v1 .
docker run --rm -p 8080:8080 demo-go-app:v1

验证:

curl http://localhost:8080

进一步优化:缓存、最小权限、可追踪性

上面的版本能用,但还不够“工程化”。下面是更推荐的版本。

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

这里做了哪些增强?

1. 启用了 BuildKit 缓存挂载

RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

这样依赖缓存不会每次都重新下载,CI 里效果尤其明显。

2. 使用 -ldflags="-s -w" 压缩二进制

go build -ldflags="-s -w" -o /out/app .

可以减少符号信息和调试信息,降低体积。

3. 运行阶段使用非 root 用户

RUN addgroup -S app && adduser -S app -G app
USER app

这是容器安全里非常基础但非常重要的一步。


更进一步:使用 distroless 运行镜像

如果你的应用是静态编译的,还可以把运行镜像换成更小、更干净的基础镜像。

# 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/app .

FROM gcr.io/distroless/static-debian12:nonroot

WORKDIR /app
COPY --from=builder /out/app /app/app

EXPOSE 8080
ENTRYPOINT ["/app/app"]

distroless 的优点

  • 镜像更小
  • 几乎没有多余工具
  • 攻击面更小

但它有边界条件

  • 没有 shell,调试不方便
  • 依赖动态库的程序要额外确认兼容性
  • 某些故障排查场景不如 Alpine 直接

所以我的建议是:

  • 开发/联调环境:可以先用 Alpine
  • 生产环境:优先评估 distroless

.dockerignore 是瘦身中最容易被忽略的一环

很多人把重点都放在 Dockerfile,结果忘了 .dockerignore。其实它经常能直接减少构建上下文大小。

.dockerignore 示例:

.git
.gitignore
Dockerfile
README.md
*.log
tmp/
dist/
coverage/
.idea/
.vscode/
node_modules/

为什么它重要?

Docker 在构建前,会把上下文发送给 Docker daemon。
如果项目目录很大,你每次构建都在传输一堆无关文件。

排查时可以观察这行输出:

[internal] load build context

如果这里显示几十 MB、几百 MB,就该优先检查 .dockerignore 了。


分阶段测试与发布

多阶段构建不只是“builder + runtime”两段。你还可以加入 test 阶段,把测试也纳入镜像构建流程。

# syntax=docker/dockerfile:1.7

FROM golang:1.22 AS deps
WORKDIR /src
COPY go.mod go.sum ./
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 builder
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/app .

FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=builder /out/app /app/app
ENTRYPOINT ["/app/app"]

只跑测试阶段:

docker build --target test -t demo-go-app:test .

构建最终运行镜像:

docker build -t demo-go-app:prod .

这套方式的优点很明显:

  • 测试、编译、运行环境边界清晰
  • CI 可以更细粒度控制失败点
  • 发布镜像中不会混入测试工具

逐步验证清单

我建议你每次优化 Dockerfile 时,不要一把梭,而是按下面清单验证。

验证 1:镜像大小是否下降

docker images | grep demo-go-app

验证 2:运行镜像里是否只剩必要文件

docker run --rm --entrypoint ls demo-go-app:v1 -l /app

如果你用的是 distroless,这一步通常不方便,因为没有 shell 和常用命令。
这也是为什么我前面说:生产用 distroless,调试用 Alpine 是一种很实用的折中方案。

验证 3:是否以非 root 运行

docker run --rm --entrypoint id demo-go-app:v1

如果镜像没有 id 命令,可以改为在应用日志中输出 UID,或者通过运行时安全策略验证。

验证 4:构建缓存是否生效

连续执行两次:

docker build -t demo-go-app:cache-test .

观察依赖下载和编译步骤是否命中缓存。


构建过程时序图

下面这张图适合帮助你理解 BuildKit、多阶段和最终交付之间的关系。

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker BuildKit
    participant Builder as Builder阶段
    participant Runtime as Runtime阶段
    participant Registry as 镜像仓库

    Dev->>Docker: docker build
    Docker->>Builder: 拉取构建基础镜像
    Builder->>Builder: 下载依赖并编译
    Builder-->>Docker: 产出二进制文件
    Docker->>Runtime: 创建最小运行镜像
    Runtime-->>Docker: 复制运行产物
    Docker->>Registry: 推送最终镜像

常见坑与排查

这一节我尽量写得“接地气”一点,因为这些问题都很常见。

1. 多阶段写了,但镜像还是很大

常见原因

  • 最终阶段仍然用了很重的基础镜像
  • COPY . . 把无关文件带进 builder,构建产物间接变大
  • 运行阶段复制了整个目录,而不是单个二进制

错误示例

COPY --from=builder /src /app

这会把源码、缓存、测试文件都拷过去。

正确示例

COPY --from=builder /out/app /app/app

2. 构建缓存总是失效

常见原因

  • COPY . .,再安装依赖
  • lock 文件频繁变化
  • 构建参数变化导致缓存失效

优化顺序

COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /out/app .

对于 Node.js、Java、Python 也同理:

  • 先复制依赖声明文件
  • 再安装依赖
  • 最后复制业务代码

3. distroless 镜像里进不去,没法调试

这是正常现象,不是你写错了。

解决办法

  • 保留一个 debug 版本镜像
  • 使用 Alpine 作为临时调试基础镜像
  • 在 Kubernetes 中用临时调试容器排查

比如保留两个目标阶段:

FROM alpine:3.20 AS debug
WORKDIR /app
COPY --from=builder /out/app /app/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-go-app:debug .

构建生产版:

docker build --target prod -t demo-go-app:prod .

4. 应用在 Alpine 能跑,到 distroless 或 scratch 就挂了

典型原因

  • 程序依赖动态链接库
  • 证书文件缺失
  • 时区文件缺失
  • CGO 依赖没有处理好

排查建议

先检查二进制是否静态链接:

file app

或者在构建阶段查看:

ldd /out/app

如果是 Go 程序,优先尝试:

RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app .

如果你的应用必须使用动态库,那就不要强行上 scratch,要基于实际依赖选运行镜像。


5. 非 root 用户运行后,文件权限报错

常见现象

  • 启动时报 permission denied
  • 应用无法写临时目录
  • 日志目录不可写

处理方式

在镜像里提前创建目录并授权:

RUN mkdir -p /app/logs && chown -R app:app /app
USER app

另外,能不写容器文件系统就尽量不写。
日志优先输出 stdout/stderr,临时文件优先挂载卷。


安全最佳实践

镜像瘦身和安全交付天然是同一条路上的事。镜像越小,通常攻击面越小;但“体积小”不等于“安全”,还要补齐下面这些动作。

1. 使用可信、明确版本的基础镜像

避免这样写:

FROM alpine:latest

更推荐:

FROM alpine:3.20

进一步可以固定 digest:

FROM alpine:3.20@sha256:xxxxxxxxxxxxxxxx

这样能减少“同名标签内容变了”的不确定性。


2. 永远不要把密钥打进镜像

错误方式:

ENV ACCESS_KEY=xxxxx
ENV SECRET_KEY=yyyyy

镜像层是可追溯的,这类信息非常容易泄露。

正确做法:

  • 构建时使用 secret mount
  • 运行时通过环境变量或密钥管理系统注入
  • 不把 .env 直接复制进镜像

如果用 BuildKit,可以这样传 secret:

docker build --secret id=mytoken,src=./token.txt -t demo-go-app:secure .

Dockerfile 中使用:

RUN --mount=type=secret,id=mytoken \
    cat /run/secrets/mytoken > /dev/null

3. 运行时最小权限

建议至少做到:

  • 非 root 用户
  • 只读文件系统(视应用而定)
  • 只开放必要端口
  • 去掉不必要 Linux capability

例如运行时:

docker run --read-only --cap-drop ALL -p 8080:8080 demo-go-app:prod

当然,是否能完全只读,取决于你的应用是否需要写缓存、上传文件或临时目录。


4. 做漏洞扫描

交付前最好把扫描接入 CI。常见工具有:

  • Trivy
  • Grype
  • Docker Scout

例如用 Trivy 扫描本地镜像:

trivy image demo-go-app:prod

需要注意一点:
扫描结果不是越多越说明你做得差,而是要区分运行时真正相关的风险。
多阶段构建的价值就在于,能把很多只属于构建阶段的依赖排除在最终镜像外。


5. 建立 SBOM 和镜像签名

在更严格的交付场景里,建议增加:

  • SBOM(软件物料清单)
  • 镜像签名
  • 来源可追踪

这能帮助你回答两个关键问题:

  • 这个镜像里到底包含了什么?
  • 这个镜像是不是我们自己构建并发布的?

性能最佳实践

除了镜像变小,我更关心“整个交付链路是否变快”。

1. 拆分依赖层和源码层

这是最基础也最有效的一招。

2. 使用 BuildKit 缓存挂载

对 Go、Node.js、Maven、pip 都有效。

3. 减少无意义文件进入上下文

优先治理 .dockerignore

4. 尽量减少层内无关操作

比如不要在多个 RUN 中重复安装和清理同一套包。

5. 选择适合的基础镜像,而不是一味追求最小

  • 追求极致最小scratch
  • 兼顾简洁与兼容:distroless
  • 兼顾调试便利:Alpine

我自己的经验是:

  • 不是所有项目都适合 scratch
  • distroless 是大多数生产服务的好平衡点
  • 如果团队排障经验不足,先从 Alpine 过渡更稳

不同方案取舍对比

方案镜像大小调试便利安全性适用场景
单阶段构建本地临时验证
多阶段 + Alpine中高中高大多数业务服务
多阶段 + distroless生产环境
多阶段 + scratch最小最低静态编译且依赖简单的程序

这里没有绝对正确答案,只有更适合当前团队能力和应用类型的方案。


一套可直接落地的建议模板

如果你想快速在团队内推广,我建议按下面顺序实施:

  1. 先补 .dockerignore
  2. 把单阶段改成多阶段
  3. 按依赖层/源码层重排 Dockerfile
  4. 引入 BuildKit 缓存
  5. 运行镜像切换为非 root
  6. 接入漏洞扫描
  7. 再评估 distroless 或更小基础镜像

这个顺序的好处是:

  • 改动成本低
  • 风险可控
  • 每一步都能看到收益
  • 不容易“一次改太多导致排查困难”

总结

Docker 多阶段构建的核心,不只是把镜像做小,而是把构建、运行、安全交付这三件事重新梳理清楚。

你可以把本文记成三句话:

  1. 构建工具不要进入运行镜像
  2. 依赖层要稳定,业务层要后置
  3. 瘦身不是终点,安全交付才是终点

如果你现在手上的 Dockerfile 还比较“原始”,最值得立刻执行的动作是:

  • .dockerignore
  • 改成多阶段构建
  • 最终镜像只复制运行产物
  • 使用非 root 用户
  • 接入扫描工具验证结果

最后补一句边界条件:
不是所有服务都要追求最小镜像。 如果你的团队当前更缺的是排障效率,而不是极致体积,那么先选 Alpine 这类更好调试的运行镜像,通常更实际。等链路稳定后,再逐步收敛到 distroless 或更严格的生产镜像方案,这样落地成功率反而更高。


分享到:

上一篇
《前端性能实战:基于 Core Web Vitals 的加载优化、长任务治理与监控落地》
下一篇
《自动化测试中的接口回归体系设计:基于 Pytest 与持续集成的实战落地指南》