Docker 多阶段构建与镜像瘦身实战:从构建优化到生产环境安全交付
很多团队刚开始用 Docker 时,最容易出现两个问题:
- 镜像又大又慢:一个简单服务,镜像几百 MB 甚至上 GB;
- 构建能跑,生产不稳:把编译工具、缓存、测试文件、密钥统统打进镜像,交付时风险很高。
我自己早期也干过这种事:Dockerfile 里从 golang:latest 一把梭,源码复制进去,编译完直接拿去上线。结果镜像体积大、拉取慢、攻击面还大,排查问题时也很痛苦。后来把多阶段构建、缓存策略、最小运行时镜像和安全扫描串起来后,交付质量提升非常明显。
这篇文章我会带你从一个“能用但不优”的 Dockerfile 出发,一步步改造成适合生产环境交付的版本。重点不是背概念,而是把这条链路真正跑通。
前置知识与环境准备
建议你本地准备:
- Docker 20.10+
- 推荐启用 BuildKit
- 一个能运行的 Go 环境(本文示例用 Go,原因是多阶段构建效果非常直观)
- 可选工具:
docker buildxdive(分析镜像层)trivy或grype(漏洞扫描)
启用 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. 镜像瘦身的核心抓手
镜像瘦身通常不是靠一个技巧,而是几个动作叠加:
- 选择更小的基础镜像
- 使用多阶段构建
- 合理安排
COPY顺序,提升缓存命中 - 使用
.dockerignore - 删除无用文件与缓存
- 只复制最终产物
- 用非 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:fatdemo-app:multidemo-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. 减少运行时包与攻击面
如果运行阶段只需要一个二进制,就不要再安装:
curlbash- 编译器
- 包管理器
- 调试工具
一句话:生产镜像不是运维跳板机。
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 多阶段构建和镜像瘦身做好,核心不是为了“炫技”,而是为了三件事:
- 构建更快
- 交付更稳
- 生产更安全
你可以先记住这几条最有执行价值的建议:
- 构建与运行分离:始终优先考虑多阶段构建;
- 缓存顺序要对:先复制依赖文件,再复制源码;
- 控制构建上下文:一定写
.dockerignore; - 运行镜像尽量最小化:只带运行所需内容;
- 默认非 root 运行;
- 不要把密钥写进镜像;
- 上线前做漏洞扫描和基本健康检查。
如果你现在的 Dockerfile 还是“一个 golang:latest 跑到底”,最好的起点不是一次性做满所有高级优化,而是先把它改成两阶段版本,然后逐步加入缓存、非 root、扫描和运行时约束。
这套路径我自己验证过很多次:收益大、风险可控,而且团队也最容易接受。