资讯动态

Docker多阶段构建实战:从1.2GB到15MB的镜像瘦身与优化

发布时间:2026/10/21 13:11:16 来源:尧图企业网站定制
1. 从一个让人抓狂的镜像体积说起你有没有遇到过这种情况本地写了个小工具用 Docker 一打包镜像体积直接飙到 1.2GB推到镜像仓库要等好几分钟拉到测试环境又得等半天同事看了一眼问你“你这镜像里到底装了啥”。你打开 Dockerfile 一看编译工具链、包管理器的缓存、源码、临时文件全在里面真正跑起来需要的可能就是一个几十兆的可执行文件。这不是个例。我见过太多项目在容器化初期都是“一把梭”式的单阶段构建拉一个基础镜像装依赖编译然后直接把这个镜像拿去跑。能跑吗能跑。但镜像里塞满了只有构建阶段才需要的东西运行时根本用不上。体积大只是表象背后还有攻击面大、启动慢、传输成本高、缓存利用率低等一系列问题。多阶段构建Multi-stage Build就是来解决这个问题的。它的核心思路非常朴素把“构建”和“运行”拆成两个甚至多个阶段构建阶段该装什么装什么最后只把构建产物复制到一个干净的精简镜像里。这样最终镜像里只有运行时真正需要的东西体积能砍掉一大半甚至更多。这篇内容适合谁看如果你已经会写基础的 Dockerfile但对多阶段构建只是“听说过、没细用过”或者用过但踩过一些坑那这篇就是写给你的。我会从原理讲到实操从单语言场景讲到多语言混合场景把那些文档里不会写的细节和坑都摊开来说。2. 多阶段构建到底在解决什么问题2.1 单阶段构建的镜像里到底装了什么先别急着上多阶段我们得先搞清楚单阶段构建的镜像为什么大。以一个典型的编译型语言项目为例单阶段 Dockerfile 大概长这样FROM ubuntu:22.04 RUN apt-get update apt-get install -y build-essential cmake git COPY . /app WORKDIR /app RUN cmake . make CMD [./myapp]这个镜像里有什么基础系统、apt 缓存、编译工具链gcc、g、make、cmake、git、源码、中间目标文件、最终可执行文件。而运行时真正需要的只有最后那个可执行文件和它依赖的动态库。其余的东西少说占了 80% 以上的体积。有人会说那我构建完手动删掉不就行了理论上可以但实际操作起来很别扭。你得在同一个 RUN 里把安装、编译、清理串起来否则层缓存会让删除操作“删了个寂寞”——因为 Docker 的镜像层是叠加的你在上一层装的东西下一层删掉体积并不会减少只是多了一个“白洞”标记。这就是为什么很多“清理型”Dockerfile 看起来删了东西镜像还是那么大。2.2 多阶段构建的核心机制多阶段构建的语法其实很简单就是在同一个 Dockerfile 里写多个FROM指令每个FROM开启一个新的构建阶段。你可以给阶段起名字然后用COPY --from从之前的阶段复制文件。FROM ubuntu:22.04 AS builder RUN apt-get update apt-get install -y build-essential cmake COPY . /app WORKDIR /app RUN cmake . make FROM ubuntu:22.04 COPY --frombuilder /app/myapp /usr/local/bin/myapp CMD [myapp]关键点在于只有最后一个阶段的产物会进入最终镜像。前面所有阶段产生的层、安装的包、编译的中间文件统统不会出现在最终镜像里。Docker 在构建时会为每个阶段维护独立的构建上下文最后只把最后一个阶段的结果导出。这背后的机制值得多说一句。Docker 的构建过程本质上是一个有向无环图DAG每个阶段是图中的一个节点。当你执行docker build时Docker 会解析所有阶段按依赖关系依次构建。COPY --frombuilder这个指令告诉 Docker我需要从 builder 这个节点的文件系统里取文件。Docker 会保留 builder 阶段的文件系统快照直到被引用完成但最终导出的镜像只包含最后一个阶段。2.3 为什么不是“用两个 Dockerfile 更简单”有人可能会想我写两个 Dockerfile一个负责构建一个负责运行构建完把产物拷出来再构建运行镜像不也一样吗确实能达到类似效果但多阶段构建的优势在于单文件管理构建逻辑和运行逻辑在同一个文件里版本同步、修改方便不会出现“改了构建脚本忘了改运行脚本”的情况。构建缓存更智能Docker 能识别阶段间的依赖关系只重建受影响的部分。两个独立 Dockerfile 的话你得自己管理中间产物的传递。CI/CD 更简洁一条docker build命令搞定不需要在流水线里写“先构建 A再拷贝产物再构建 B”这种多步操作。构建参数传递方便ARG可以在阶段间传递构建时的参数控制更灵活。所以多阶段构建不是“多此一举”而是把原本需要外部脚本协调的流程内聚到了 Dockerfile 内部。3. 不同语言场景下的多阶段构建实操3.1 Go 项目静态编译是天然搭档Go 语言做多阶段构建是最舒服的因为 Go 可以编译成静态链接的二进制文件运行时几乎不依赖系统库。一个典型的 Go 多阶段 DockerfileFROM golang:1.21-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -ldflags-s -w -o /app/server ./cmd/server FROM alpine:3.19 RUN apk add --no-cache ca-certificates tzdata COPY --frombuilder /app/server /usr/local/bin/server USER nobody ENTRYPOINT [server]这里有几个细节值得展开。CGO_ENABLED0是为了确保静态编译避免运行时依赖 glibc。-ldflags-s -w是去掉符号表和调试信息能再减小 20% 到 30% 的体积。go mod download单独一层是为了利用缓存——只要 go.mod 和 go.sum 没变这层就不会重建后面改代码时构建速度会快很多。最终镜像基于 alpine加上 ca-certificates 和 tzdata体积大概在 15MB 左右。对比单阶段用 golang 镜像的 800MB差距非常明显。注意如果你的项目用了 cgo 或者依赖了 glibc 特有的功能CGO_ENABLED0可能会编译失败或运行异常。这时候要么改用CGO_ENABLED1并确保运行镜像里有对应的动态库要么换用gcr.io/distroless/static这类专门为静态二进制设计的运行镜像。3.2 Java 项目分层构建与 JRE 精简Java 的多阶段构建稍微复杂一点因为 Java 应用通常需要 JRE 才能运行而且构建产物可能是一个 fat jar 或者一堆依赖。先看一个基础版本FROM maven:3.9-eclipse-temurin-21 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -B COPY src ./src RUN mvn package -DskipTests -B FROM eclipse-temurin:21-jre-alpine WORKDIR /app COPY --frombuilder /build/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]mvn dependency:go-offline的作用和 Go 的go mod download类似先把依赖拉下来缓存住。这样改代码时不会重新下载依赖。但 Java 场景有个进阶玩法利用 Spring Boot 的分层 jar 特性。Spring Boot 2.3 支持把 fat jar 拆成 layers让不常变的依赖层和常变的应用层分开进一步提升镜像拉取和构建的效率。FROM eclipse-temurin:21-jre-alpine WORKDIR /app COPY --frombuilder /build/target/app.jar app.jar RUN java -Djarmodelayertools -jar app.jar extract然后在 Dockerfile 里按 layers 的顺序 COPYCOPY --frombuilder /build/target/dependencies/ ./ COPY --frombuilder /build/target/spring-boot-loader/ ./ COPY --frombuilder /build/target/snapshot-dependencies/ ./ COPY --frombuilder /build/target/application/ ./ ENTRYPOINT [java, org.springframework.boot.loader.launch.JarLauncher]这样做的收益是当你只改了业务代码时只有 application 层会变dependencies 层可以复用缓存推送和拉取镜像时只需要传输变化的那一层。3.3 Node.js 项目区分构建依赖和运行依赖Node.js 的多阶段构建有个特殊之处前端项目需要构建比如 webpack 打包后端项目可能只需要npm install --production。两种情况的多阶段策略不太一样。前端项目FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:1.25-alpine COPY --frombuilder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf后端项目FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --production npm cache clean --force COPY --frombuilder /app/dist ./dist CMD [node, dist/main.js]后端这里有个容易踩的坑运行阶段重新执行了npm ci --production而不是直接从 builder 复制 node_modules。为什么因为 builder 阶段的 node_modules 里包含了 devDependencies直接复制过去会把开发依赖也带进运行镜像。重新安装虽然多花一点时间但能保证运行镜像干净。提示npm ci比npm install更适合 CI/CD 环境它会严格按照 package-lock.json 安装保证每次构建的依赖版本一致。如果你的项目没有 lock 文件先执行一次npm install生成它。3.4 Python 项目虚拟环境与 wheel 缓存Python 的多阶段构建有个麻烦点很多包需要编译比如带 C 扩展的包构建时需要编译工具链运行时不需要。用多阶段可以这样处理FROM python:3.12-slim AS builder WORKDIR /build RUN apt-get update apt-get install -y --no-install-recommends gcc python3-dev COPY requirements.txt . RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt FROM python:3.12-slim WORKDIR /app COPY --frombuilder /wheels /wheels RUN pip install --no-cache-dir --no-index --find-links/wheels /wheels/* rm -rf /wheels COPY . . CMD [python, main.py]这里用pip wheel先把所有依赖编译成 wheel 文件然后在运行阶段直接安装 wheel。好处是运行阶段不需要 gcc 和 python3-dev镜像能小很多。--no-index --find-links确保只从本地 wheel 目录安装不会去 PyPI 重新下载。4. 多阶段构建中那些文档不会告诉你的坑4.1 COPY --from 的路径陷阱COPY --frombuilder /app/dist ./dist这行看起来很简单但路径问题经常让人翻车。第一个坑是源路径是相对于构建阶段的工作目录还是根目录。答案是COPY --from的源路径始终相对于该阶段的文件系统根目录而不是WORKDIR。所以如果你在 builder 阶段WORKDIR /build产物在/build/output那COPY --frombuilder /build/output ./才是对的写成COPY --frombuilder output ./会找不到文件。第二个坑是通配符和目录复制行为。COPY --frombuilder /app/dist ./dist如果 dist 是个目录Docker 会把目录内容复制到./dist下。但如果./dist不存在Docker 会创建它。这个行为和普通 COPY 一致但在多阶段场景下容易搞混因为源路径在另一个阶段里报错信息不够直观。4.2 构建参数在阶段间的传递ARG在多阶段构建里的作用域是个容易混淆的点。每个阶段是独立的ARG不会自动跨阶段传递。如果你在第一个阶段定义了ARG VERSION1.0第二个阶段想用必须重新声明FROM golang:1.21 AS builder ARG VERSION1.0 RUN go build -ldflags-X main.version${VERSION} -o /app/server FROM alpine:3.19 ARG VERSION1.0 LABEL version${VERSION} COPY --frombuilder /app/server /usr/local/bin/server注意第二个阶段重新声明了ARG VERSION但不需要给默认值给了也行但构建时传入的值会覆盖。这个细节在需要把版本号、构建时间等信息注入到运行镜像标签或环境变量时特别重要。4.3 缓存失效的连锁反应多阶段构建的缓存机制比单阶段复杂因为阶段之间有依赖。一个常见的困惑是我明明只改了运行阶段的 CMD为什么构建时把 builder 阶段也重新跑了一遍原因通常出在COPY . .这一行。如果你的.dockerignore没配好把.git、node_modules、target这些目录也复制进去了那每次构建时这些目录的内容变化都会导致COPY . .这一层的缓存失效进而导致后面的所有层重建。解决办法是认真配置.dockerignore.git node_modules target dist *.log .env另一个缓存陷阱是阶段命名和引用顺序。如果你在 Dockerfile 里先写了运行阶段再写构建阶段然后用COPY --frombuilder引用后面才定义的阶段Docker 会报错。阶段的定义顺序必须满足依赖关系被引用的阶段要先定义。4.4 运行镜像的基础镜像选择多阶段构建的最后一步是选一个合适的基础镜像来跑产物。这个选择直接决定了最终镜像的体积和安全性。常见选项对比基础镜像体积约适用场景注意事项alpine5MB静态二进制、对体积敏感musl libcglibc 程序可能不兼容debian-slim30MB需要 glibc 的动态链接程序比完整 debian 小很多distroless2-20MB生产环境、安全要求高没有 shell调试困难scratch0MB完全静态的二进制没有任何文件连证书都没有ubuntu70MB需要完整系统工具体积大不推荐用于运行阶段选 alpine 时要注意 musl 和 glibc 的兼容性问题。Go 静态编译没问题但 Rust 默认链接 glibc直接放到 alpine 里跑会报not found错误。解决办法是编译时指定--target x86_64-unknown-linux-musl或者运行阶段改用 debian-slim。distroless 是另一个值得考虑的选择它只包含应用和运行时依赖没有包管理器、shell、甚至没有ls。安全性很好但调试时很痛苦——你没法docker exec进去看文件。我的建议是生产环境用 distroless但保留一个基于 alpine 的调试版本用于排查问题。5. 把多阶段构建用出花来的几个进阶思路5.1 多阶段做“构建矩阵”多阶段构建不只能做“构建运行”两段还可以用来做多平台、多配置的构建矩阵。比如你需要为不同的 CPU 架构编译不同的二进制FROM --platform$BUILDPLATFORM golang:1.21 AS builder-amd64 ARG TARGETOS TARGETARCH RUN GOOSlinux GOARCHamd64 go build -o /app/server-amd64 FROM --platform$BUILDPLATFORM golang:1.21 AS builder-arm64 RUN GOOSlinux GOARCHarm64 go build -o /app/server-arm64 FROM alpine:3.19 ARG TARGETARCH COPY --frombuilder-${TARGETARCH} /app/server-${TARGETARCH} /usr/local/bin/server配合docker buildx的--platform参数可以一次构建出多架构镜像。这个模式在需要同时支持 x86 和 ARM 服务器的场景下非常实用。5.2 用多阶段做测试和 lint多阶段构建的另一个妙用是把测试和 lint 也纳入构建流程但不让它们污染最终镜像FROM golang:1.21 AS base WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . FROM base AS test RUN go test ./... FROM base AS lint RUN go vet ./... FROM base AS builder RUN CGO_ENABLED0 go build -o /app/server FROM alpine:3.19 COPY --frombuilder /app/server /usr/local/bin/server这样你可以用docker build --target test .只跑测试用docker build --target lint .只跑 lint而默认构建不指定 target只会走到最后一个阶段。CI 流水线里可以并行跑 test 和 lint 阶段通过后再构建最终镜像。5.3 构建缓存挂载BuildKit 的隐藏技能如果你用的是 BuildKitDocker 18.09 默认启用可以用--mounttypecache来挂载构建缓存进一步提升重复构建的速度FROM golang:1.21 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN --mounttypecache,target/go/pkg/mod go mod download COPY . . RUN --mounttypecache,target/root/.cache/go-build CGO_ENABLED0 go build -o /app/server这个特性能让 Go 的模块缓存和编译缓存跨构建复用第二次构建时速度提升非常明显。类似的用法在 apt、pip、npm 等场景也适用RUN --mounttypecache,target/var/cache/apt apt-get update apt-get install -y ...注意--mounttypecache需要 BuildKit 支持在旧版 Docker 上不生效。可以通过DOCKER_BUILDKIT1 docker build显式启用或者在 daemon 配置里默认开启。5.4 镜像体积的“最后一公里”优化多阶段构建做完之后如果还想继续压体积还有几个方向可以挖UPX 压缩对 Go 或 Rust 的二进制用 UPX 压缩体积能再减 50% 到 70%但启动时会多一点点解压时间。适合对体积极度敏感、对启动速度不那么敏感的场景。strip 符号表Go 用-ldflags-s -wC/C 用strip命令去掉调试符号。合并 RUN 层把多个 RUN 合并成一个减少层数。虽然对体积影响不大但能减少镜像的元数据开销。选择更小的基础镜像从 alpine 换到 distroless或者从 debian-slim 换到 alpine每换一次都能省几十兆。但要注意优化到一定程度后收益会递减。一个 15MB 的镜像再压到 10MB实际意义可能不如把构建时间从 5 分钟降到 2 分钟。优化的优先级应该根据实际痛点来定如果瓶颈在传输就压体积如果瓶颈在构建速度就优化缓存策略。6. 我踩过的几个真实坑和排查思路6.1 镜像里莫名多出的文件有一次构建完发现运行镜像比预期大了 200MB但 Dockerfile 里明明只 COPY 了一个二进制文件。排查过程是这样的先用docker history看每一层的大小发现有一层特别大。然后docker run --rm -it image sh进去看发现/tmp目录下有一堆编译时的临时文件。原因是 builder 阶段的WORKDIR设在了/tmp而COPY --frombuilder /app/server只复制了二进制但 Docker 在导出最终镜像时某些情况下会把构建阶段的临时文件也带进来——准确说是如果最终阶段的WORKDIR和 builder 阶段相同且没有显式清理某些构建器版本会有这个行为。解决办法是在最终阶段显式设置一个干净的WORKDIR并且只 COPY 需要的文件。这个坑的教训是不要假设 Docker 会自动帮你清理显式声明永远比隐式行为可靠。6.2 动态链接库找不到用 Rust 写的一个服务builder 阶段用rust:1.75编译运行阶段用alpine:3.19启动时报Error loading shared library libgcc_s.so.1。原因是 Rust 默认链接 glibc而 alpine 用的是 musl两者不兼容。排查思路先用ldd看二进制的动态库依赖确认它链接了哪些库。然后要么改用rust:1.75-alpine作为 builder 并指定 musl target要么运行阶段改用debian:bookworm-slim。我最后选了后者因为改 target 需要调整不少编译配置而 debian-slim 只比 alpine 大了 25MB可以接受。6.3 构建缓存“该失效时不失效”有一次改了代码里的一个常量重新构建时发现 builder 阶段没有重新编译用的是旧缓存。排查后发现是COPY . .这一行前面有一层RUN go mod download而COPY . .的缓存判断是基于文件内容的哈希。问题在于我的.dockerignore里漏了*.sum文件导致 go.sum 的变化没有触发缓存失效。这个问题的隐蔽性在于它不会报错只是默默地用了旧代码。解决办法是仔细检查.dockerignore确保所有会影响构建结果的文件都在监控范围内。一个实用的技巧是在 CI 里加一步docker build --no-cache的验证构建确保没有缓存时也能正常构建。6.4 多阶段构建的调试方法多阶段构建调试起来比单阶段麻烦因为你不能直接docker run到中间阶段。几个实用的调试技巧用--target停在中间阶段docker build --target builder -t myapp:builder .可以只构建到 builder 阶段然后docker run进去看文件系统。用docker build --progressplain显示完整的构建输出包括每个阶段的执行细节方便定位是哪一步出了问题。在 Dockerfile 里临时加RUN ls -la /app虽然土但有效。构建时能看到目录内容确认文件是否在预期位置。用docker history看层大小快速定位哪一层异常大缩小排查范围。这些方法看起来简单但在实际排查时能省很多时间。我习惯在写新 Dockerfile 时先加几个临时的RUN ls和RUN du -sh确认路径和体积符合预期后再删掉。7. 关于多阶段构建我的几点个人体会多阶段构建不是银弹它解决的是“构建环境和运行环境耦合”这个特定问题。如果你的项目本身就是解释型语言、不需要编译或者运行环境本来就很小那多阶段构建的收益可能有限。但只要你的项目涉及编译、打包、资源处理中的任何一环多阶段构建几乎都是值得的。我自己的习惯是任何需要编译的镜像默认就用多阶段。模板固定下来之后新项目直接套边际成本几乎为零。而且多阶段构建带来的不只是体积优化还有构建流程的清晰化——构建阶段该装什么装什么运行阶段该精简就精简职责分明维护起来也舒服。最后一个建议不要追求“极致小”的镜像。一个 20MB 的镜像和一个 15MB 的镜像在实际使用中差别微乎其微但为了那 5MB 可能要引入 musl 兼容问题、调试困难、构建时间变长等代价。找到体积、构建速度、可维护性之间的平衡点比单纯追求最小体积重要得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑