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

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

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

Docker 多阶段构建与镜像瘦身实战:从构建优化到生产环境安全交付

很多团队刚开始用 Docker 时,最容易出现两个问题:

  1. 镜像又大又慢:一个简单服务,镜像几百 MB 甚至上 GB;
  2. 构建能跑,生产不稳:把编译工具、缓存、测试文件、密钥统统打进镜像,交付时风险很高。

我自己早期也干过这种事:Dockerfile 里从 golang:latest 一把梭,源码复制进去,编译完直接拿去上线。结果镜像体积大、拉取慢、攻击面还大,排查问题时也很痛苦。后来把多阶段构建、缓存策略、最小运行时镜像和安全扫描串起来后,交付质量提升非常明显。

这篇文章我会带你从一个“能用但不优”的 Dockerfile 出发,一步步改造成适合生产环境交付的版本。重点不是背概念,而是把这条链路真正跑通。


前置知识与环境准备

建议你本地准备:

  • Docker 20.10+
  • 推荐启用 BuildKit
  • 一个能运行的 Go 环境(本文示例用 Go,原因是多阶段构建效果非常直观)
  • 可选工具:
    • docker buildx
    • dive(分析镜像层)
    • trivygrype(漏洞扫描)

启用 BuildKit:

export DOCKER_BUILDKIT=1

背景与问题

先看一个典型的“初学者版本” Dockerfile。

FROM golang:1.22

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

EXPOSE 8080
CMD ["./server"]

这个写法的问题很集中:

  • 基础镜像太大golang 镜像本身就包含完整编译工具链;
  • 构建与运行环境混在一起:生产镜像里保留了编译器、包管理器等不必要内容;
  • 缓存利用差COPY . . 太早,任何源码变动都会让依赖重新下载;
  • 可能把无关文件打进去.git、测试数据、构建产物、密钥文件;
  • 安全面更宽:运行时镜像组件越多,潜在漏洞面越大。

从交付链路看,这些问题会带来:

  • CI 构建慢
  • 镜像仓库占用大
  • 生产部署拉取慢
  • CVE 扫描结果多
  • 运维排障复杂

核心原理

1. 多阶段构建的本质

多阶段构建不是“语法技巧”,而是一种构建阶段与运行阶段解耦的思路。

  • 第一阶段:负责编译、测试、打包
  • 第二阶段:只放运行所需的最少文件

也就是说,编译器存在于构建过程,但不进入最终镜像

flowchart LR
    A[源码与依赖] --> B[构建阶段 Builder]
    B --> C[生成二进制/静态资源]
    C --> D[运行阶段 Runtime]
    D --> E[生产镜像]

2. 镜像瘦身的核心抓手

镜像瘦身通常不是靠一个技巧,而是几个动作叠加:

  1. 选择更小的基础镜像
  2. 使用多阶段构建
  3. 合理安排 COPY 顺序,提升缓存命中
  4. 使用 .dockerignore
  5. 删除无用文件与缓存
  6. 只复制最终产物
  7. 用非 root 用户运行

3. Docker 构建缓存的工作机制

Docker 会按层缓存。哪一层变了,后面的层通常都要重建。

所以顺序很关键:

  • 依赖定义文件变化少,应尽量前置
  • 源码变化频繁,应后置
sequenceDiagram
    participant Dev as 开发者
    participant Docker as Docker Build
    participant Cache as 本地/远端缓存

    Dev->>Docker: docker build
    Docker->>Cache: 检查 go.mod / go.sum 层
    alt 依赖未变
        Cache-->>Docker: 命中缓存
    else 依赖变更
        Docker->>Docker: 重新下载依赖
    end
    Docker->>Cache: 检查源码复制层
    Docker->>Docker: 编译生成产物
    Docker-->>Dev: 输出最终镜像

实战代码(可运行)

下面我们用一个最小 Go Web 服务演示完整过程。

目录结构

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

示例应用代码

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))
}

go.mod

module demo-app

go 1.22

先看一个“可运行但不推荐”的版本

FROM golang:1.22

WORKDIR /app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 go build -o server .

EXPOSE 8080
CMD ["./server"]

构建并查看体积:

docker build -t demo-app:fat .
docker images | grep demo-app

这个版本通常能跑,但最终镜像会偏大。


第一步:改成多阶段构建

推荐 Dockerfile

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/server .

FROM alpine:3.20

WORKDIR /app

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

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

USER app:app

EXPOSE 8080
ENTRYPOINT ["/app/server"]

这个版本已经比前面的版本进步很大:

  • 构建工具链留在 builder
  • 运行镜像只保留二进制
  • 使用 alpine 作为更轻量的运行环境
  • 用非 root 用户运行

构建和运行

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

测试:

curl http://localhost:8080

预期输出:

hello from multi-stage docker build

第二步:继续瘦身,优化缓存与构建速度

使用 .dockerignore

.dockerignore

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

这一步非常重要。很多人镜像大,不只是 Dockerfile 问题,而是构建上下文过大
你本地目录里的无关文件如果被传给 Docker daemon,不仅构建慢,还可能污染缓存。

用 BuildKit 缓存模块下载

如果你使用 BuildKit,可以把依赖缓存进一步做得更好:

# 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 alpine:3.20

WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder /out/server /app/server

USER app:app
EXPOSE 8080
ENTRYPOINT ["/app/server"]

优点:

  • 依赖下载和构建缓存可以复用
  • CI 中重复构建明显更快

第三步:针对生产环境进一步缩小运行镜像

如果你的 Go 程序是纯静态编译,理论上可以用更小的运行时镜像,例如 scratch

极简版运行镜像

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

FROM scratch
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

这个版本体积会更小,但要注意边界条件:

  • 如果程序依赖 CA 证书、时区数据、动态链接库,scratch 可能不够用;
  • 排查问题不方便,因为里面几乎什么工具都没有。

实战建议

  • 追求极致体积:用 scratch
  • 兼顾可维护性:优先 alpine 或 distroless

第四步:更稳的生产交付版本

下面给一个更接近生产实践的版本:加入健康检查、标签信息和更安全的运行方式。

# syntax=docker/dockerfile:1.7

FROM golang:1.22 AS builder

ARG VERSION=dev
ARG COMMIT_SHA=unknown

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 -X main.version=${VERSION} -X main.commit=${COMMIT_SHA}" \
    -o /out/server .

FROM alpine:3.20

LABEL org.opencontainers.image.title="demo-app"
LABEL org.opencontainers.image.description="demo app for multi-stage docker build"
LABEL org.opencontainers.image.source="local"
LABEL org.opencontainers.image.version="${VERSION}"

WORKDIR /app

RUN addgroup -S app && adduser -S app -G app \
    && apk add --no-cache ca-certificates \
    && chown -R app:app /app

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

USER app:app

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/ || exit 1

ENTRYPOINT ["/app/server"]

构建命令:

docker build \
  --build-arg VERSION=1.0.0 \
  --build-arg COMMIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo unknown) \
  -t demo-app:1.0.0 .

逐步验证清单

你可以按下面顺序验证,避免“一把改完不知道哪里出问题”。

1. 验证镜像是否变小

docker images | grep demo-app

对比:

  • demo-app:fat
  • demo-app:multi
  • demo-app:1.0.0

2. 查看镜像层

docker history demo-app:multi

观察是否还存在大量构建工具链和缓存文件。

3. 启动容器验证功能

docker run --rm -p 8080:8080 demo-app:multi
curl http://localhost:8080

4. 验证运行用户

docker run --rm demo-app:multi id

应看到不是 root。

5. 漏洞扫描

trivy image demo-app:multi

通常你会发现,精简运行时镜像后,漏洞数量会明显下降。


常见坑与排查

这一节我尽量写得接地气一些,因为这些问题确实是实操里最常见的。

坑 1:COPY . . 放太早,缓存几乎废掉

现象

  • 改一行代码,依赖又重新下载;
  • CI 构建时间长得离谱。

原因

  • Docker 会把整个源码目录变化视为当前层变化;
  • 后续 go mod download 层无法复用。

修正

先复制依赖定义文件,再下载依赖,最后复制源码。

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

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

现象

程序访问外部 HTTPS 服务时报证书错误。

原因

scratch 里没有 CA 证书。

排查

如果日志里出现类似 x509: certificate signed by unknown authority,大概率就是这个问题。

解决方案

在 builder 阶段准备证书,再复制进去,或改用 alpine/distroless

示例:

FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /server /server
ENTRYPOINT ["/server"]

坑 3:容器里运行报“权限不足”

现象

切换非 root 后,程序无法写临时目录或日志目录。

原因

文件所有权没有处理好。

解决方案

  • 运行前创建目录并 chown
  • 避免写入镜像内只读路径
  • 优先把日志输出到 stdout/stderr
RUN addgroup -S app && adduser -S app -G app \
    && mkdir -p /app/data \
    && chown -R app:app /app
USER app:app

坑 4:镜像明明多阶段了,还是很大

常见原因

  • 最终阶段又安装了一堆调试工具
  • .dockerignore 缺失
  • 构建产物本身大
  • 静态资源未压缩
  • 最终镜像里复制了多余目录

排查命令

docker history your-image:tag

或者用 dive

dive your-image:tag

它能很直观地看到每一层到底塞了什么。


坑 5:alpine 运行报 libc 相关错误

现象

程序在 builder 能编译,但在运行时启动失败,提示动态库缺失。

原因

可能启用了 CGO,二进制依赖 glibc/musl 差异。

建议

  • 尽量 CGO_ENABLED=0
  • 如果必须启用 CGO,builder 和 runtime 的 libc 环境要匹配
  • 复杂场景不要盲目追求 scratch

安全/性能最佳实践

这一部分我按“生产交付最值得做的动作”来列。

1. 不要把秘密写进镜像

反面例子:

ENV DB_PASSWORD=123456

这类信息会进入镜像元数据,极易泄露。

更好的方式:

  • 运行时通过环境变量注入
  • 用 Docker Secret / K8s Secret
  • 构建时用 BuildKit secret,不落盘

BuildKit secret 示例:

# syntax=docker/dockerfile:1.7
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

构建命令:

docker build --secret id=npmrc,src=$HOME/.npmrc -t app .

2. 尽量使用固定版本,不要滥用 latest

不推荐:

FROM alpine:latest

推荐:

FROM alpine:3.20

原因很简单:

  • 构建结果可复现
  • 避免上游镜像突然变化导致线上行为漂移

3. 使用非 root 用户运行

这是最容易落地、收益又很高的安全动作。

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

如果容器被突破,非 root 能降低进一步扩权的风险。


4. 减少运行时包与攻击面

如果运行阶段只需要一个二进制,就不要再安装:

  • curl
  • bash
  • 编译器
  • 包管理器
  • 调试工具

一句话:生产镜像不是运维跳板机。


5. 做镜像扫描,但别只看“数量”

扫描命令:

trivy image demo-app:1.0.0

看扫描结果时建议关注:

  • 是否存在高危/严重漏洞
  • 是否在可利用路径上
  • 是否来自运行时镜像而不是构建阶段
  • 是否有可升级版本

不是每个 CVE 都要“立刻全量阻断”,但高危且可利用的要优先处理。


6. 为 CI/CD 设计缓存策略

如果你在 CI 中频繁构建,可以考虑:

  • 将依赖层独立出来
  • 使用 buildx 缓存导入导出
  • 使用远程缓存仓库

示例:

docker buildx build \
  --cache-to=type=local,dest=.buildx-cache \
  --cache-from=type=local,src=.buildx-cache \
  -t demo-app:ci .

7. 结合最小权限运行参数

仅 Dockerfile 不够,运行参数也很关键:

docker run --rm \
  --read-only \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  -p 8080:8080 \
  demo-app:1.0.0

如果你的应用需要写临时文件,再显式挂载可写目录。


一个完整的交付思路

把前面的实践串起来,生产交付通常可以按下面流程走。

flowchart TD
    A[源码提交] --> B[Docker 多阶段构建]
    B --> C[缓存复用与依赖下载]
    C --> D[生成最小运行镜像]
    D --> E[漏洞扫描]
    E --> F[功能验证与健康检查]
    F --> G[推送镜像仓库]
    G --> H[生产部署]

如果你们团队已经有 CI/CD,这张图基本就能直接映射成流水线步骤。


方案取舍:不是越小越好

这里我想特别提醒一点:镜像瘦身不是竞赛,不是越小越高级。

常见选择可以这么看:

方案体积可维护性安全性适用场景
直接用构建镜像运行本地开发、临时验证
多阶段 + alpine中小大多数业务服务
多阶段 + distroless很高生产服务、规范化团队
多阶段 + scratch最小很高纯静态二进制、强控环境

我的经验是:

  • 团队刚开始治理镜像:先落地“多阶段 + .dockerignore + 非 root”
  • 已经进入规范化阶段:再尝试 distroless 或 scratch
  • 如果排障能力不足:不要过早把镜像做到极致,维护成本会反噬你

总结

把 Docker 多阶段构建和镜像瘦身做好,核心不是为了“炫技”,而是为了三件事:

  • 构建更快
  • 交付更稳
  • 生产更安全

你可以先记住这几条最有执行价值的建议:

  1. 构建与运行分离:始终优先考虑多阶段构建;
  2. 缓存顺序要对:先复制依赖文件,再复制源码;
  3. 控制构建上下文:一定写 .dockerignore
  4. 运行镜像尽量最小化:只带运行所需内容;
  5. 默认非 root 运行
  6. 不要把密钥写进镜像
  7. 上线前做漏洞扫描和基本健康检查

如果你现在的 Dockerfile 还是“一个 golang:latest 跑到底”,最好的起点不是一次性做满所有高级优化,而是先把它改成两阶段版本,然后逐步加入缓存、非 root、扫描和运行时约束。
这套路径我自己验证过很多次:收益大、风险可控,而且团队也最容易接受。


分享到:

上一篇
《Web逆向实战:从浏览器抓包到还原加签逻辑的完整分析方法》
下一篇
《Docker 镜像体积优化实战:多阶段构建、层缓存与构建提速方案》