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

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

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

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

这个写法的问题很典型:

  1. 构建环境直接进入运行镜像
  2. 源码全部进入最终镜像
  3. 基础镜像偏大
  4. 缓存层设计不合理,改一行代码就全量重建
  5. 默认 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,这当然有帮助,但不够。

镜像大小通常由几部分组成:

  • 基础镜像大小
  • 应用依赖大小
  • 构建工具链大小
  • 缓存与临时文件
  • 被误打包进去的无关文件

所以真正有效的瘦身策略往往是组合拳:

  1. 多阶段构建
  2. 合理排序 Dockerfile 层
  3. 使用 .dockerignore
  4. 选择合适的基础镜像
  5. 清理缓存和临时文件
  6. 非 root 运行
  7. 减少运行时依赖

3. 为什么它还能加速构建

多阶段构建本身不一定天然加速,但配合缓存设计后效果明显

比如先复制 go.modgo.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 构建后拷到 alpinedistroless

原因通常有:

  • 二进制依赖动态链接库,但运行镜像里没有
  • 架构不匹配
  • 文件路径写错
  • 可执行权限异常

排查方法:

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。

如果你需要排障,可以采用两套镜像策略:

  • 开发/排障镜像:基于 alpinedebian: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、少依赖

逐步验证清单

如果你打算把现有项目改造成多阶段构建,可以按下面顺序做,不容易乱。

第一步:确认运行时真正需要什么

问自己三个问题:

  1. 最终镜像到底要运行什么文件?
  2. 这些文件依赖哪些动态库?
  3. 是否必须保留 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,我建议按这个顺序落地:

  1. 先拆成 builder/runtime 两阶段
  2. 调整 COPY 顺序,确保依赖层可缓存
  3. 加上 .dockerignore
  4. 使用非 root 用户
  5. 再考虑 Alpine / distroless 等更小底座
  6. 最后用扫描工具验证安全收益

最关键的一点是:别一上来就追求最小,而是追求“足够小、足够稳、足够好排障”
这样做出来的镜像,才是真的适合长期维护。


分享到:

上一篇
《从零搭建企业级 AI 知识库问答系统:基于 RAG 的数据清洗、检索优化与效果评测实践》
下一篇
《前端性能优化实战:基于 Web Vitals 的页面加载与交互体验提升方案》