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

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

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

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

很多团队一开始用 Docker 时,能跑就行:Dockerfile 里一股脑装依赖、编译、打包、启动,最后镜像能有多大算多大。等服务多了、CI 变慢了、镜像仓库越来越胖、漏洞扫描一片红,问题就集中爆发了。

我自己第一次认真做镜像治理时,最直观的感受就是:镜像瘦身不是“省几十 MB”这么简单,它会直接影响构建速度、发布效率、攻击面和运维成本。 而多阶段构建(Multi-stage Build)就是这件事里最实用的一把刀。

这篇文章我会带你从问题出发,拆清楚多阶段构建的核心原理,然后给出一套可运行的实战示例,包括:

  • 为什么镜像会越来越胖
  • 多阶段构建到底解决了什么
  • 如何一步步把镜像从“开发机打包机”改造成“最小运行时”
  • 如何配合 .dockerignore、缓存、非 root 用户、安全基线做完整优化
  • 常见坑怎么排查

背景与问题

先看一个很常见的“原始版” Dockerfile。以 Go 服务为例:

FROM golang:1.21

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

EXPOSE 8080
CMD ["./app"]

它的问题很典型:

  1. 构建环境和运行环境混在一起
    • Go 编译器、包管理工具、缓存文件都被带进最终镜像
  2. 镜像体积大
    • golang 基础镜像就不小
  3. 安全面更大
    • 镜像里工具越多,被利用的可能性越高
  4. 构建缓存利用率差
    • COPY . . 放得太早,代码一改,后续层缓存几乎全失效
  5. 不利于多环境发布
    • 测试、构建、生产往往应该关注点不同,但这里全混在一起了

一个典型的演进问题链

flowchart TD
    A[单阶段 Dockerfile] --> B[镜像包含编译器/依赖缓存]
    B --> C[镜像体积膨胀]
    B --> D[漏洞扫描项增加]
    C --> E[拉取/推送变慢]
    E --> F[CI/CD 变慢]
    D --> G[安全治理成本上升]

如果你现在的项目已经出现以下症状,那基本就是该重构镜像了:

  • CI 构建越来越慢
  • 镜像动辄几百 MB 甚至上 GB
  • 漏洞扫描结果大量来自“其实运行时根本用不到”的包
  • 开发环境可用,线上启动却慢、排查也复杂

前置知识与环境准备

建议你本地准备以下环境:

  • Docker 20.10+
  • 推荐启用 BuildKit
  • 一个可运行的 Go 项目示例

启用 BuildKit:

export DOCKER_BUILDKIT=1

如果你使用 Docker Desktop,通常已经默认支持。

本文实战示例目录如下:

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

示例代码

main.go

package main

import (
	"encoding/json"
	"log"
	"net/http"
	"os"
)

func main() {
	http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]string{
			"status": "ok",
			"host":   hostname(),
		})
	})

	log.Println("server started at :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func hostname() string {
	h, err := os.Hostname()
	if err != nil {
		return "unknown"
	}
	return h
}

go.mod

module demo-go-app

go 1.21

.dockerignore

.git
.gitignore
README.md
Dockerfile
tmp
dist
node_modules
*.log

核心原理

多阶段构建的核心思路可以概括成一句话:

在前面的阶段里“尽情构建”,在最后的阶段里“只保留运行必需品”。

也就是说:

  • builder 阶段:安装编译工具、拉依赖、构建产物
  • runtime 阶段:只拷贝二进制文件或打包结果,不带编译环境

多阶段构建的结构

flowchart LR
    A[源码] --> B[builder 阶段]
    B --> C[下载依赖]
    C --> D[编译产物]
    D --> E[runtime 阶段]
    E --> F[仅复制可执行文件]
    F --> G[最终瘦身镜像]

为什么它能同时提升性能和安全

因为它把“构建需要”和“运行需要”分开了:

  • 构建阶段可能需要:
    • 编译器
    • 包管理器
    • 头文件
    • 调试工具
  • 运行阶段通常只需要:
    • 可执行文件
    • 必要证书
    • 少量运行时依赖

结果是:

  • 镜像更小
  • 层更少
  • 漏洞暴露更少
  • 启动和分发更快

Docker 层缓存为什么很关键

Docker 每一步都会形成一层。只要某一层的输入变了,后续层通常都要重新执行。所以 Dockerfile 指令顺序直接影响构建速度。

正确思路一般是:

  1. 先复制依赖描述文件
  2. 安装依赖
  3. 再复制业务代码
  4. 最后编译

这样改业务代码时,不会导致依赖下载层失效。


实战代码(可运行)

下面我们从“普通版”一步步改到“生产版”。


第一步:一个可工作的单阶段版本

先看对照组:

FROM golang:1.21-alpine

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

EXPOSE 8080
CMD ["./app"]

构建:

docker build -t demo-go:single .

运行:

docker run --rm -p 8080:8080 demo-go:single

验证:

curl http://localhost:8080/health

虽然能跑,但这还不是我们想要的结果。


第二步:改造成多阶段构建

这是一个更接近生产可用的版本:

FROM golang:1.21-alpine 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/app .

FROM alpine:3.18 AS runtime

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app

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

USER appuser

EXPOSE 8080
ENTRYPOINT ["/app/app"]

构建:

docker build -t demo-go:multi .

运行:

docker run --rm -p 8080:8080 demo-go:multi

验证:

curl http://localhost:8080/health

这个版本做了什么优化

  • AS builder 标记构建阶段
  • 依赖下载和源码拷贝分开,提升缓存命中率
  • CGO_ENABLED=0 编译静态二进制,减少运行时依赖
  • -ldflags="-s -w" 去掉调试符号,缩小二进制体积
  • 最终镜像使用更小的 alpine
  • 使用非 root 用户运行

第三步:进一步瘦身到极简运行时

如果你的 Go 程序不依赖 shell,也不需要在容器中调试,可以进一步使用 scratch

FROM golang:1.21-alpine AS builder

WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN apk add --no-cache ca-certificates && \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /app/app .

FROM scratch

WORKDIR /app
COPY --from=builder /app/app /app/app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

EXPOSE 8080
ENTRYPOINT ["/app/app"]

构建:

docker build -t demo-go:scratch .

什么时候适合 scratch

适合:

  • 静态编译产物
  • 不需要 shell
  • 不依赖动态链接库
  • 希望极限瘦身

不太适合:

  • 需要排查问题时进入容器调试
  • 程序依赖系统库
  • 某些语言运行时不能轻易静态化

我个人经验是:业务早期优先用 Alpine 或 Distroless,成熟后再考虑 scratch。 因为排障便利性也很重要。


构建提速:缓存优化的关键写法

很多人以为多阶段构建只解决“体积”,其实它对构建速度的影响同样非常大。

不推荐的写法

FROM golang:1.21-alpine AS builder
WORKDIR /src
COPY . .
RUN go mod download
RUN go build -o /app/app .

问题在于:

  • 任意源码变动都会导致 COPY . . 这一层失效
  • 后面的 go mod downloadgo build 基本都会重新执行

推荐的写法

FROM golang:1.21-alpine AS builder
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download

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

这样只有依赖定义变更时才会重新下载模块。

使用 BuildKit 缓存挂载

更进一步,可以用 BuildKit 做依赖缓存:

# syntax=docker/dockerfile:1.4

FROM golang:1.21-alpine 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 \
    go build -ldflags="-s -w" -o /app/app .

这个技巧在 CI 中很实用,尤其是模块较多时,效果会很明显。

构建流程时序图

sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Build
    participant Cache as 层缓存/BuildKit 缓存
    participant Registry as 镜像仓库

    Dev->>Docker: 提交源码并触发构建
    Docker->>Cache: 检查 go.mod/go.sum 对应缓存
    alt 依赖未变
        Cache-->>Docker: 命中依赖层
    else 依赖已变
        Docker->>Docker: 重新下载依赖
    end
    Docker->>Docker: 编译二进制
    Docker->>Docker: 生成最小运行时镜像
    Docker->>Registry: 推送更小的最终镜像

安全/性能最佳实践

镜像瘦身和安全优化最好一起做,不要拆开看。

1. 选择更合适的基础镜像

常见选择:

  • alpine
    • 小,通用,调试相对方便
  • distroless
    • 更少组件,攻击面更小
  • scratch
    • 极简,但调试成本高

一个简单判断:

  • 需要兼顾调试和体积:alpine
  • 偏生产安全:distroless
  • 极限瘦身:scratch

2. 不要用 root 运行

很多镜像默认是 root,这在生产里并不理想。

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

如果你用的是 distroless,也可以用它自带的非 root 变体。


3. 用 .dockerignore 减少无效上下文

这个点经常被忽略,但收益很高。否则:

  • .git 历史会被打包进构建上下文
  • 本地临时文件也会传给 Docker daemon
  • 上下文越大,构建越慢

建议至少忽略:

.git
node_modules
dist
tmp
coverage
.env

尤其注意:不要把敏感文件误打进镜像上下文。


4. 固定基础镜像版本

不建议直接写:

FROM alpine:latest

建议固定小版本,甚至使用 digest:

FROM alpine:3.18

更严格一点:

FROM alpine@sha256:...

这样可以避免“今天能构建,明天突然炸”的漂移问题。


5. 尽量减少层和无效包

例如在 Alpine 中安装依赖时:

RUN apk add --no-cache ca-certificates

不要留下额外缓存。

如果是 Debian/Ubuntu 系镜像,记得及时清理:

RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*

6. 把漏洞扫描纳入流程

常见工具:

  • Trivy
  • Grype
  • Docker Scout

例如使用 Trivy:

trivy image demo-go:multi

镜像越精简,通常扫描结果越干净,也更容易真正聚焦到需要修复的问题。


7. 尽量让容器“只做一件事”

不要把:

  • 业务进程
  • 定时任务
  • 调试工具
  • 迁移脚本

全部塞进同一个镜像里。镜像职责越单一,越好维护,也越容易瘦身。


常见坑与排查

这一部分很重要。多阶段构建本身不复杂,但实战中经常会遇到一些“明明写对了,却跑不起来”的问题。


坑 1:COPY --from=builder 路径写错

例如:

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

但你的 builder 阶段实际输出到了 /src/app,就会报找不到文件。

排查方法:

  • 检查 go build -o 的输出路径
  • 确保 COPY --from=... 的源路径与构建阶段一致

坑 2:用了 scratch 后 HTTPS 请求失败

症状:

  • 调用外部 HTTPS 接口时报证书错误

原因:

  • scratch 里没有 CA 证书

解决:

COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt

坑 3:程序在 Alpine 能编译,在运行时崩溃

常见原因:

  • 依赖了动态链接库
  • CGO 相关配置不兼容

排查建议:

  1. 尝试静态编译:
    RUN CGO_ENABLED=0 go build -o /app/app .
  2. 如果必须 CGO:
    • 评估是否继续使用 Alpine
    • 或改成 Debian slim / distroless 对应版本

坑 4:缓存没命中,构建还是很慢

常见原因:

  • 太早执行 COPY . .
  • .dockerignore 没配好
  • 依赖文件频繁变化
  • CI 每次都是全新环境,未启用远程缓存

排查思路:

  • 先看 Dockerfile 指令顺序
  • 再看构建日志里哪一层总在重新执行
  • 最后看 CI 是否支持 BuildKit cache

坑 5:切换非 root 用户后没有权限

症状:

  • 启动时报文件不可读或目录不可写

解决方式:

RUN mkdir -p /app && chown -R appuser:appgroup /app
USER appuser

如果要写日志或临时文件,提前把目录权限处理好。


坑 6:镜像变小了,但构建时间反而更长

这不是不可能,常见于:

  • builder 阶段做了过多额外处理
  • 使用了过小基础镜像,安装依赖耗时更高
  • 多架构构建引入额外开销

判断标准别只看镜像体积,还要综合看:

  • 冷构建时间
  • 热构建时间
  • 镜像拉取时间
  • 部署时间
  • 排障成本

一套推荐的生产级 Dockerfile 模板

如果你现在要落地,我建议从下面这个模板开始,而不是一上来就追求极限 scratch

# syntax=docker/dockerfile:1.4

FROM golang:1.21-alpine 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.18 AS runtime

RUN apk add --no-cache ca-certificates && \
    addgroup -S appgroup && adduser -S appuser -G appgroup

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

USER appuser

EXPOSE 8080
ENTRYPOINT ["/app/app"]

这个模板的平衡点比较好:

  • 有缓存
  • 镜像较小
  • 有 CA 证书
  • 非 root
  • 排障成本比 scratch 更低

逐步验证清单

你可以按下面这份清单验证自己的优化是否真的生效。

功能验证

docker build -t demo-go:prod .
docker run --rm -p 8080:8080 demo-go:prod
curl http://localhost:8080/health

体积对比

docker images | grep demo-go

对比:

  • demo-go:single
  • demo-go:multi
  • demo-go:scratch

用户检查

进入容器查看当前用户(非 scratch 镜像):

docker run --rm demo-go:multi id

漏洞扫描

trivy image demo-go:multi

构建缓存验证

修改 main.go 某个响应字段后重新构建:

docker build -t demo-go:multi .

观察 go mod download 是否命中缓存。


方案取舍:别把“最小”误认为“最好”

做镜像治理时,我一般不建议把“体积最小”当作唯一目标。更准确的目标应该是:

  • 满足运行需求的最小镜像
  • 可接受的构建速度
  • 可维护的排障成本
  • 足够低的攻击面

一个简单对比:

方案体积调试便利安全性适用场景
单阶段构建本地试验、临时验证
多阶段 + Alpine中小中高大多数业务服务
多阶段 + Distroless生产服务
多阶段 + Scratch极小最低很高静态编译、成熟稳定服务

如果你的团队刚开始治理镜像,我建议路线是:

  1. 先从单阶段改为多阶段
  2. 再优化缓存与 .dockerignore
  3. 然后落地非 root 与漏洞扫描
  4. 最后评估是否升级到 Distroless / Scratch

这个顺序更稳,也更容易在团队里推广。


总结

Docker 多阶段构建的价值,绝不只是“把镜像做小一点”。它本质上是在帮你完成一件更重要的事:

把构建环境和运行环境彻底分离,让镜像更轻、更快、更安全。

你可以把本文的核心建议记成 6 条:

  1. 构建与运行分阶段
  2. 依赖文件先复制,提升缓存命中
  3. .dockerignore 控制构建上下文
  4. 最终镜像只保留运行必需品
  5. 使用非 root 用户运行容器
  6. 把漏洞扫描和版本固定纳入流程

如果你现在就要落地,我建议最先做这三件事:

  • 把现有单阶段 Dockerfile 改成多阶段
  • 调整 COPY 顺序,优先利用缓存
  • 给运行时镜像加上非 root 用户和最小基础镜像

边界条件也要记住:

  • 不是所有项目都适合 scratch
  • 镜像越小,不一定越容易维护
  • 安全优化要结合调试和交付效率一起权衡

做得好的镜像,应该是团队愿意长期维护的镜像,而不是一次性“炫技”产物。多阶段构建,就是这条路上最值得优先投入的一步。


分享到:

上一篇
《安卓逆向实战:基于 Frida 与 JADX 的登录参数加密流程定位与 Hook 分析》
下一篇
《从抓包到补环境:中级开发者实战 Web 逆向中的签名参数还原与请求重放》