先交代一个真实场景我有一段时间特别排斥做镜像瘦身总觉得“能跑就行体积大点无所谓”。直到某次往测试环境推一个功能只有两个接口的 Go 服务镜像拉取卡在进度条上一动不动我盯着终端看了快三分钟才跑完。回头一查Dockerfile 写得极其偷懒直接拿golang:1.21开发镜像做基础镜像编译完也不删任何东西最后镜像体积高达 986MB。一个编译出来只有 9MB 的静态二进制凭什么要拖着一个几百 MB 的开发环境到处跑后来我把这个 Dockerfile 改成多阶段构建镜像体积从 986MB 压到 15.7MB直接砍了 98%。在绝大多数项目里哪怕你用的是 Node.js、Python 或者 Java只要学会多阶段构建的思路镜像体积减少 80% 完全不是夸张说法。这篇文章就围绕“多阶段构建”这个核心技巧讲清楚它为什么能瘦身、怎么改 Dockerfile、改成什么样以及我在实操中踩过的坑。适合正在用 Docker 搭项目、觉得镜像太胖但又不知道从哪下手的开发者。不需要你有多深的容器底层知识只要会用 Dockerfile 基础指令就可以跟着把这套优化思路落到自己的项目里。1. 镜像体积失控的那些坑为什么一个普通服务会占到近1GB很多人第一次发现自己镜像很大时的反应是“没事本地磁盘够用”。但我劝你把这件事认真对待因为镜像体积不是只占你本地硬盘那点地方它会在好几个环节一起给你添堵。1.1 体积背后是“开发环境”和“运行环境”混在一起先想一个问题一个用 Go 写的 Web 服务它跑起来的时候真正需要的文件是什么答案很简单——只需要一个编译好的可执行文件外加可能用到的静态资源、配置文件、证书文件。它根本不需要 Go 编译器、不需要 Go 源码、不需要go mod下载的那些依赖包源码。但传统的单阶段 Dockerfile 写法恰好把这一切全塞进去了。典型的写法是这样FROM golang:1.21 WORKDIR /app COPY . . RUN go mod download RUN go build -o server . EXPOSE 8080 CMD [./server]golang:1.21这个基础镜像自带完整的 Go 开发工具链、系统包、各种编译中间文件镜像体积本身就接近 800MB。你在这个基础上再叠上项目源码、依赖模块、编译产物最终镜像轻松突破 1GB。更尴尬的是这些体积里 95% 是服务运行根本用不到的“开发现场”。换到 Node.js 项目上就更夸张很多人会抱着整个node_modules一起打进镜像一个十几行的 Express 服务硬生生做出 1.2GB 的镜像。这其实不是 Docker 的问题是构建思路出了问题——你把毛坯房里的脚手架、水泥袋、施工队全打包好然后拎着这一整套搬进了新家。1.2 体积大到底会让谁买单镜像体积直接影响的环节比想象中多我列几个最常见的受影响环节具体表现我在实际项目里的感受镜像拉取体积越大拉取越慢尤其是内网网速一般的情况近 1GB 的镜像在测试环境每次部署都像在下载大型游戏磁盘占用本地 Docker、私有仓库、部署服务器都要存储一个仓库存十几个版本的镜像磁盘很快告急启动速度容器启动时要准备文件系统、加载依赖体积大的镜像冷启动明显更慢对频繁扩缩容的服务的体验影响显著安全暴露面镜像里附带 shell、编译器、调试工具越多攻击者越容易利用多一个 python、curl 就有可能多一条渗透路径这些痛苦平时不太显眼但一旦你开始做持续集成持续部署、动态扩缩容、多环境交付就会被无限放大。拉镜像慢直接拖慢发布速度存储占用上涨意味着要多花钱扩磁盘安全暴露面在合规审计时每一层要翻出来查。所以镜像瘦身不是“锦上添花”是真正能改善部署体验和降低维护成本的事。2. 多阶段构建的工作原理它凭什么能把体积砍到只剩产物想用好多阶段构建不能光会抄 Dockerfile得先理解它背后“构建环境和运行环境分离”的这个设计逻辑。2.1 传统 Dockerfile 为什么做不到瘦身Dockerfile 里的每个指令都会被记录成一个镜像层Layer这些层一层叠一层形成最终镜像。只要你在同一个FROM基础上做任何操作比如apt-get install装依赖、go build编译这些中间状态全都留在层里最后变成镜像体积的一部分。很多人为了瘦身会在同一条 RUN 指令里把临时文件顺手删掉比如RUN apt-get update apt-get install -y build-essential rm -rf /var/lib/apt/lists/*这确实能减少一点体积但治标不治本。编译工具链装进去的那几百 MB 不可能通过简单rm删干净因为底层文件系统已经记下了整个变更状态你删完文件后镜像层只是多了一条“删除记录”物理空间未必真正释放更不可能把golang镜像自带的开发环境变没。所以传统思路的困境是一套 Dockerfile 只能有一个基础镜像、一个生命周期。你想在里面既完成编译、又拥有一个精简干净的运行环境根本做不到。2.2 多阶段构建的“隔离施工区”逻辑多阶段构建出现后这个问题迎刃而解。你可以在同一个 Dockerfile 里写多个FROM每个FROM开启一个独立阶段每个阶段用不同的基础镜像。前一阶段无论装了什么工具、下了多少依赖都只是临时环境只有你手动用COPY --fromxxx复制到最终阶段的文件才会进入最终镜像。你可以把它想象成装修房子。第一阶段是“施工阶段”毛坯房里堆满了水泥、沙袋、电钻工人在里面敲敲打打这个阶段用什么工具都没关系第二阶段是“入住阶段”你不会把水电师傅和电钻搬进客厅你只把做完的成品家具搬进去。最终镜像就是那个干净的房子里面只有你要的东西。这也是多阶段构建和普通 Dockerfile 最本质的区别它把“用来构建的镜像”和“用来运行的镜像”拆开了。这是镜像瘦身里最核心的思维转换。2.3 FROM 与 COPY --from 的组合使用多阶段构建的语法非常简单核心就是两个点FROM golang:1.21 AS builder这是给第一阶段起一个别名builder。后面的阶段想要从这个阶段拿文件就写COPY --frombuilder /app/server /serverCOPY --from可以指定从别名阶段复制也可以直接指定从某个已存在的镜像复制比如从外部镜像里提取一个证书文件但绝大多数场景下都是从自己的构建阶段复制文件。最终镜像只包含你通过COPY --from显式复制的内容以及最终阶段里 RUN 等指令产生的新层。整个builder阶段占用的空间不会算进最终镜像。这就是为什么多阶段构建能把一个 986MB 的镜像压到 15MB 左右。3. 实战改造一个Go服务从986MB压到15.7MB的完整过程原理说再多不如直接看一次完整的改造过程。我用一个 Go 写的 HTTP 服务作为例子改造前后的 Dockerfile 和体积数据都是真实可复现的。3.1 改造前的单阶段Dockerfile与问题点原始 Dockerfile 我有意写得比较“标准”因为很多项目就是这么起步的FROM golang:1.21 WORKDIR /app COPY . . RUN go mod download RUN CGO_ENABLED0 go build -o server . EXPOSE 8080 CMD [./server]构建命令docker build -t demo:single .镜像体积的分解大致是这样golang:1.21基础镜像本身约 800MB项目源码和 Go 依赖模块约 100MB编译工具链和中间文件约 80MB最终得到的demo:single镜像接近 986MB由此可见服务本身只有 9MB 的二进制体积的绝大多数来自开发环境。这个问题单阶段 Dockerfile 是解决不了的。3.2 多阶段版本与关键参数解读改造后的 Dockerfile 如下# 阶段一构建现场用完整 Go 开发镜像 FROM golang:1.21 AS builder WORKDIR /app # 先拷贝依赖描述文件利用 Docker 层缓存 COPY go.mod go.sum ./ RUN go mod download # 再拷贝源码并编译 COPY . . RUN CGO_ENABLED0 GOOSlinux go build -ldflags-s -w -o server . # 阶段二运行现场用 scratch 空镜像 FROM scratch WORKDIR / COPY --frombuilder /app/server /server EXPOSE 8080 ENTRYPOINT [/server]几个值得关注的细节CGO_ENABLED0必须加这样编译出来的是完全静态链接的二进制不依赖任何.so动态库才能放进scratch空镜像里跑。GOOSlinux建议显式声明如果你在 macOS 或 Windows 上构建不声明交叉编译参数构建出来的二进制放到 Linux 容器里跑不起来。-ldflags-s -w可以顺手加作用是去掉符号表和 DWARF 调试信息能把二进制再减掉几 MB。COPY go.mod go.sum ./放在COPY . .前面这能让依赖下载这层利用 Docker 构建缓存。只要go.mod、go.sum没变即使源码改了也不会重新执行go mod download构建速度会快非常多。FROM scratch是一个空镜像没有任何 shell、没有 ls、没有 ca-certificates只有你的二进制。看起来简陋但攻击面也最小。3.3 构建对比与体积结果同样的源码两种写法各自构建docker build -t demo:multi . docker images我这里实际拿到的数据是镜像版本最终体积demo:single单阶段986MBdemo:multi多阶段15.7MB体积减少了约 98%远超标题里的 80%。如果你的服务里还带前端静态资源多阶段构建一样能把这些静态文件复制过去不会丢功能。如果你的服务还需要 HTTPS 访问scratch镜像里没有系统根证书有两个解决方案。要么在运行阶段安装证书对于一个“空镜像”来说比较麻烦更常见的是在 builder 阶段把证书复制进来FROM alpine:3.19 AS certs RUN apk add --no-cache ca-certificates FROM scratch COPY --fromcerts /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt这样最终镜像里就有公网可信证书可以正常发起 HTTPS 请求。4. 不止GoNode.js与Python项目如何套用同一套逻辑多阶段构建不是只给 Go 用的凡是“构建过程需要一堆工具运行过程只需要产物”的项目都可以按同一套思路来做瘦身。4.1 Node.js把npm install和构建结果拆出来Node.js 服务最常见的镜像体积杀手是基础镜像用node:18这种完整开发版、把整个node_modules一起拷进镜像、生产环境里还留着devDependencies。这三刀下来镜像随随便便上 1GB。改造思路是第一阶段安装依赖并完成npm run build第二阶段只保留运行依赖和构建产物。一个实用的例子# 阶段一安装依赖并构建 FROM node:18 AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 阶段二精简运行环境 FROM node:18-alpine WORKDIR /app ENV NODE_ENVproduction # 安装生产依赖只包含 dependencies不含 devDependencies COPY package*.json ./ RUN npm ci --omitdev npm cache clean --force # 从构建阶段拿产物 COPY --frombuilder /app/dist ./dist EXPOSE 3000 CMD [node, dist/main.js]这里关键点有两个npm ci --omitdev只装生产依赖比直接 COPY 整个node_modules干净很多。npm cache clean --force去掉 npm 缓存避免它又被记入镜像层。如果你的项目没有任何构建步骤、也不需要原生模块还可以更激进一点第二阶段直接用node:18-alpine然后把node_modules整个从 builder 阶段复制过来甚至可以不用再执行一次npm ci。但要注意如果项目里有bcrypt、sharp这类原生模块它们依赖系统库跨阶段复制很容易出现平台不匹配这类情况反而建议在最终阶段重新安装。4.2 Python用slim镜像加用户级依赖安装Python 项目的表现比 Node 还极端因为pip往往会把一堆二进制依赖临时编译文件留在镜像里。我看到过很多人用一个python:3.12完整镜像装完依赖后镜像接近 1.5GB。多阶段改造的基本思路# 阶段一安装依赖 FROM python:3.12-slim AS builder WORKDIR /app COPY requirements.txt . # 安装到用户目录完成后只拷这部分 RUN pip install --user --no-cache-dir -r requirements.txt # 阶段二精简运行镜像 FROM python:3.12-slim WORKDIR /app COPY --frombuilder /root/.local /root/.local COPY . . # 把用户级依赖包路径加入 PATH 和 PYTHONPATH ENV PATH/root/.local/bin:$PATH ENV PYTHONPATH/root/.local/lib/python3.12/site-packages CMD [python, app.py]这样做的核心是把pip install产生的依赖安装到独立目录最终阶段只把这部分复制过去不带任何 pip 缓存和临时文件。如果还想更小可以把第二阶段换成python:3.12-alpine但要注意 PyPI 上有不少包在 musl 上编译会有各种各样的兼容问题Python 项目选slim通常比alpine更稳。4.3 Java特别注意JDK和JRE的区别Java 项目没有 Go 那么明显的“单二进制优势”因为它运行时离不开 JVM。但多阶段构建依然有效第一阶段用 Maven/Gradle 镜像打包出 fat jar第二阶段只装 JRE 不装 JDK镜像我见过从 800MB 降到 280MB 的例子。FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests FROM eclipse-temurin:17-jre WORKDIR /app COPY --frombuilder /app/target/*.jar app.jar ENTRYPOINT [java, -jar, app.jar]JDK 和 JRE 的差距非常大JDK 里带着 javac、jdb、javap 等等开发调试工具光这层就多出两百多 MB跑生产项目完全用不到。有更高追求的话还可以用jlink按需裁剪 JRE把不用的模块去掉但那是一个更复杂的课题新手先把 JDK 换成 JRE 就够开心一阵子了。5. 想再省更多多阶段构建之外的瘦身组合拳多阶段构建是主力但不是全部。想达到“减少 80%”甚至更好的效果通常要配合另外几个手段一起打组合拳。5.1 基础镜像选型scratch、distroless、alpine、slim如何选很多人在基础镜像上踩的第一个坑是看到别人用alpine自己也跟着用结果依赖装不上了。基础镜像的选择其实和你的项目运行依赖强相关不能说哪个小就无脑换。我的建议可以整理成一张表基础镜像体积量级特点适合场景scratch几KB空镜像无 shell、无包管理器、无系统库Go/Rust 静态编译二进制distroles几十MB只有运行最小依赖无 shell有动态依赖但想最小化暴露面的服务alpine5MB左右busybox musl 库包管理器很轻量无原生依赖或已确认兼容的二进制debian slim几十MBglibc 环境兼容性好依赖较多、怕兼容问题的服务选型时我的经验是Go 项目优先尝试 scratch有 HTTP 外呼需求、需要 CA 根证书的话在证书问题解决后仍可放心用 scratch。Java 和 Python 优先考虑 distroless 或 slim别轻易用 alpine 当默认选择。5.2 层合并与清理不让中间产物进入最终镜像多阶段构建解决的是“开发环境混入运行环境”的问题但如果你在最终阶段还做了一堆操作比如apt-get install、下源码、编译这些中间状态一样会变成层留下痕迹。这里有一条最实用的经验把安装、使用、清理压缩在同一条 RUN 指令里。举例RUN apt-get update \ apt-get install -y --no-install-recommends curl \ curl -fsSL https://example.com/script.sh | bash \ apt-get purge -y curl \ apt-get autoremove -y \ rm -rf /var/lib/apt/lists/*这样设计能确保中间产品不散落成多层。如果分成三条 RUN哪怕最后删了文件镜像层里也会多记几次变更历史。5.3 利用BuildKit缓存与.dockerignore除了多阶段构建另外一个我反复强调的建议是给项目补一个.dockerignore文件。很多人忽略了它导致构建上下文太大。node_modules dist/ .git .idea *.log Dockerfile README.md这个文件的本质是告诉 Docker “这些文件不要打包进构建上下文”。没有它Docker 会把项目目录整个打包发送给守护进程比如一个带着几 GBnode_modules的目录光构建前的上下文上传就要卡半天。开启 BuildKit 之后还能在 RUN 阶段启用--mounttypecache比如RUN --mounttypecache,target/root/.cache/go-build \ go build -o server .这样每次构建的编译缓存会挂载在外部缓存里不会写进镜像层构建速度和体积都能受益。如果你还在用 Docker 19 以下的老版本建议先把 Docker 升级BuildKit 默认开启已经好几年了新版本没必要再犹豫。6. 踩坑记录多阶段构建使用中我遇到的五个问题多阶段构建的语法本身不难但实际用起来有不少隐蔽的坑新手经常会被这些细节卡住。我把印象比较深的几个问题写出来算是给大家排雷了。6.1 scratch没有shell别在CMD里用.shscratch镜像里没有任何可执行文件之外的东西没有/bin/sh、没有/bin/bash。如果你在CMD里写数组形式没问题比如[/server]但如果写成 shell 字符串形式比如CMD /serverDocker 会自动在前面补/bin/sh -c然后在 scratch 里没有 shell 就会直接报no such file or directory。换成ENTRYPOINT [/server]或写好CMD数组形式就没事。6.2 二进制有动态依赖却放进scratch启动即报错不是所有 Go 二进制都能塞进 scratch。如果你用CGO_ENABLED1编译或者用了依赖 C 库的库ldd看一下会发现它依赖libc.so.6之类的动态库。这时候放 scratch 里会出现经典的standard_init_linux.go: exec user process caused no such file or directory报错。遇到这种依赖最简单的办法不是折腾 scratch而是换debian:stable-slim或者alpine装上对应的运行库或者干脆在 build 时把CGO_ENABLED0想办法打开。多阶段构建的核心思路是“运行环境尽量小”不是非要小到 scratch。6.3 COPY --from路径和阶段名必须精确匹配COPY --frombuilder /app/server /server的三个部分里阶段名builder拼错、源路径写错、目标路径没创建都会报错。有些新手会把阶段名和镜像名搞混FROM golang:1.21 AS builder后面就只能写builder不能写golang:1.21。还有一个容易踩的点如果源阶段里没有生成对应文件COPY --from会把整个目录复制过去但内容为空。比如你编译命令输出了./bin/app但后面写的是/app/app镜像体积不会变大但容器一启动就报找不到文件。6.4 ARG与ENV的跨阶段传递陷阱多阶段构建里每个阶段默认带着自己的环境变量ARG只在声明阶段有效ENV只影响当前阶段及后续继承的阶段。如果你是这么写的FROM alpine AS build ARG VERSION1.0 RUN echo $VERSION FROM scratch COPY --frombuild /app /app第二个阶段里是拿不到VERSION的。想在多个阶段共用变量通常要在每个阶段里各自声明一次ARG VERSION或者用.env配合--build-arg。这里的坑很容易藏在“最终的运行环境需要某个环境变量”这类需求里最好是显式在最终阶段用ENV声明一次避免 COPY 过去的配置读取不到。6.5 缓存失效的控制依赖文件优先COPY最开始写多阶段 Dockerfile 时我习惯把COPY . .写在最前面。后来构建次数变多才发现每次只要源码改一个字符go mod download、npm ci这些又贵又慢的步骤全部重新跑一遍构建时间几乎翻倍。优化方式是调整 COPY 顺序先拷go.mod、go.sum、package.json、package-lock.json一类依赖描述文件跑完依赖下载再拷源码。这样只要依赖文件没变后面的依赖缓存能直接复用构建速度会快很多。看似只是调整了两行顺序实际体验差别巨大。还有个小坑是COPY . .可能会把本地构建的临时产物也拷进去比如node_modules、dist、.git。控制这个问题靠.dockerignore这也是为什么我在前面单独讲了那一节。一个顺手能用的小建议最后分享一个我实际养成的小习惯拿到任何别人的 Dockerfile我都会先问一句“这个镜像最终运行到底需要哪些文件”。这个问题想明白镜像瘦身方向基本就对了至少一半。多阶段构建本质就是这句话的落地手段——把“施工环境”和“入住环境”彻底分开只把最需要的东西带进最终镜像。你现在就可以挑一个日常在跑的小项目哪怕只有两三个接口把 Dockerfile 改成多阶段构建跑一次docker images看看体积变化。我保证那一瞬间的对比数据比你读十篇优化教程都更有说服力。