Docker多阶段构建:从820MB到21MB的镜像瘦身

📅 发布时间:2026/9/15 16:59:49
Docker多阶段构建:从820MB到21MB的镜像瘦身
我有段时间特别烦写 Dockerfile。项目一多每个镜像都从同一个基础镜像改出来编译工具、依赖缓存、测试框架全塞在里面一个业务镜像动辄七八百兆CI 上一跑就是十几分钟。后来有个 Go 服务改用多阶段构建镜像直接从 820MB 掉到 21MB构建时间也砍掉一大截。那次之后我基本把所有服务的 Dockerfile 都重写了一遍也陆续帮同事改过不少。这篇文章就聊聊 Docker 多阶段构建的完整思路、常用套路和踩过的坑适合那些正在纠结镜像体积怎么控制、CI 构建为什么越来越慢、或者刚接触 Docker 还没搞懂多阶段到底在解决什么问题的读者。1. 多阶段构建的思路拆解为什么老镜像又大又慢1.1 传统 Dockerfile 到底差在哪先说一个最常见的单阶段 Dockerfile比如一个 Go 服务很多人一开始是这样写的FROM golang:1.22 WORKDIR /app COPY . . RUN go build -o server . EXPOSE 8080 CMD [./server]这个写法在本地跑起来没有任何问题但放到真实环境里问题就慢慢暴露了。首先golang:1.22 镜像里带着完整的编译工具链、GOPATH 缓存、git、各类调试工具这些运行时根本用不上。其次这种写法的缓存策略很糟糕只要源码里任何一个文件发生变化COPY . .这一层就会失效紧接着RUN go build也要全量重编依赖下载那部分时间一分都省不下来。更隐蔽的问题在安全性上。构建工具链意味着镜像里多了一堆可以执行的文件万一镜像被拉取到一个不那么可信的环境中这些多余组件会显著放大攻击面。之前我接手过一个 Java 项目镜像直接基于maven:3.9-jdk-17里面全是构建依赖和本地仓库缓存生产环境根本不需要这些但镜像体积摆在那里每次发布都要拉好几百兆网络差一点的时候整个发布流程能被拖到怀疑人生。如果你只是本地演示、跑个玩具项目单阶段怎么写都行。可一旦要上 CI、要发布、要在多台机器之间同步镜像这种“构建环境和运行环境混在一起”的写法就非常吃亏。所有问题归根到底是一件事你需要的只是编译出来的那个产物却把整个工厂一起搬到了生产环境。1.2 多阶段构建的“前后厨”思路多阶段构建解决的就是这个模型问题。一个 Dockerfile 里可以写多个FROM每个FROM开启一个新阶段阶段之间可以用AS命名。Docker 默认只会保留最后一个阶段的内容前面的阶段都只是中间过程最终镜像里不会出现它们的层。打个比方这就像餐厅的后厨和餐桌后厨负责备菜、烹饪、装盘但端到客人面前的只有成品锅碗瓢盆、调料罐子全都留在后厨。你要是直接把整个后厨搬到餐桌旁边那画面想想就可怕。多阶段构建做的事情就是这样让“做菜”的过程和“上菜”的结果彻底分离。这种设计带来的好处是连锁反应。镜像体积小了拉取和传输时间自然降下来最终阶段没有编译工具安全风险也小构建阶段可以独立缓存依赖层不会因为源码改动就反复失效有些阶段之间没有依赖关系时Docker 还可以并行处理CI 速度也会改善。它不是某个巧妙的语法糖而是一种工程习惯每个镜像都应该只包含运行它真正需要的东西。这套思路不挑语言。Go 静态编译友好可以做到很极致前端项目编译完只剩静态文件交给 Nginx 托管就完事Java 至少能把 Maven/Gradle 和 JDK 里的编译器踢出去Python 也能把安装依赖和跑应用拆开。核心逻辑永远是同一句话把构建工具从运行时镜像里请出去。2. 核心细节与缓存优化要点2.1 阶段命名与 COPY 规则先记住两个基础语法。第一个是给阶段起名FROM golang:1.22 AS builder第二个是从指定阶段复制文件COPY --frombuilder /out/server /app/serverDocker 也支持COPY --from0这种用阶段索引的方式表示从第一个阶段复制。但这种写法我不推荐长期使用因为一旦你在 Dockerfile 中间插入一个新阶段后面的索引全部错位排查起来非常难受。有名字不用非要用编号纯粹给后人挖坑。还有一个小细节值得注意COPY --from复制的是那个阶段文件系统里的文件不是构建日志里打印出来的路径。很多人在RUN go build -o server .之后以为产物在/server但实际 WORKDIR 是/app产物在/app/server。等COPY --frombuilder /server /app/server执行时就报文件不存在了。我现在的习惯是镜像内的路径全部写绝对路径不依赖默认工作目录这样两个阶段之间的文件交接会明确很多。2.2 依赖安装放在 COPY 源码之前这是多阶段构建里最值得花时间的优化点因为它的收益是所有技巧里最明显的。Docker 的构建缓存按指令逐层判断RUN判断的是命令字符串本身以及它所读取的文件内容COPY和ADD判断的是被复制文件的元数据和内容。简单说只要源码里某个文件变了COPY . .之后的所有层都缓存失效。所以正确顺序是把“变化最少的部分”放在最前面把“最容易变化的部分”放最后。以 Go 项目为例FROM golang:1.22 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o /out/server .这样设计之后只要go.mod和go.sum没变go mod download这一层就会命中缓存。你改十次业务代码依赖下载一次都不需要重新执行。Node 项目同理FROM node:20 AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run buildnpm ci比npm install更适合构建场景它严格按照 lockfile 安装、不更新版本、速度更快而且在 CI 环境里可复现性更强。这里再提一个 BuildKit 下的新玩法RUN --mounttypecache可以挂一个持久化的缓存目录进去比如 Go 的模块缓存可以这样写RUN --mounttypecache,target/go/pkg/mod go mod download缓存挂载和普通层缓存不是一回事它不会进入镜像层只在构建机上持久化。好处是即使相关层失效重新执行命令时还能复用一部分依赖下载的缓存速度提升明显。但要注意使用这种语法时命令本身要尽量无副作用缓存目录要随时可以被清空否则可能引入状态不一致的问题。2.3 最后阶段的轻量化和安全收尾多阶段构建最后一步是确定最终镜像长什么样。这一步常见的坑有两个一是照抄 builder 阶段的基础镜像二是完全不考虑运行时的依赖文件。最终阶段到底选什么基础镜像取决于你的程序怎么编译的。Go 服务如果静态编译了理论上最终镜像可以只留一个可执行文件用scratch甚至都行。但实际使用中我通常还是选alpine或distroless因为程序可能需要 CA 证书、时区数据或者/etc/passwd这类基础文件手动往里抠太麻烦。如果程序依赖 glibc那就老老实实用debian-slim不要为了体积硬上alpine否则运行时会遇到no such file or directory那个报错会让你查很久。最终阶段还要处理两个安全习惯。第一个是创建非 root 用户不要让容器默认以 root 身份跑业务进程。第二个是只复制必要文件.git目录、测试文件、本地配置、日志文件都不应该进最终镜像。经验法则很简单最终阶段里没有出现的指令和文件都不会出现在镜像里你写进最终阶段的东西每一行都要能解释清楚为什么需要它。3. 完整实操案例从 Dockerfile 到镜像瘦身3.1 案例一Go 服务镜像从 820MB 到 21MB我有一个内部服务功能不复杂就是标准库加两个开源依赖早期用单阶段 Dockerfile 构建镜像体积 820MB构建时间在缓存失效时能到 6 分钟。后来我把它改成多阶段构建最终镜像只有 21MB构建时间也大幅缩短。Dockerfile 长这样# syntaxdocker/dockerfile:1 FROM golang:1.22 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -trimpath -ldflags-s -w -o /out/server . FROM alpine:3.20 RUN apk add --no-cache ca-certificates tzdata \ adduser -D -u 10001 app USER app COPY --frombuilder /out/server /app/server EXPOSE 8080 ENTRYPOINT [/app/server]构建就一条命令docker build -t demo/server:v1 .构建完可以用docker history查看每一层的大小确认体积主要落在哪。这个项目优化前后的对比如下项目单阶段多阶段基础镜像golang:1.22alpine:3.20镜像体积820MB21MB构建时间无缓存约 6 分钟约 50 秒镜像内工具链完整编译工具、git、GOPATH 缓存仅 CA 证书、时区数据、业务二进制默认运行用户root非 root 用户 app这个案例里几个编译参数值得解释。CGO_ENABLED0表示关闭 CGO编译出纯静态二进制这是后续能用alpine甚至scratch作为基础镜像的前提。GOOSlinux是目标系统如果你在 Mac 上构建不指定它就会编译出 Darwin 可执行文件放进 Linux 容器里直接跑不起来。-trimpath会去掉二进制里的本地路径信息避免泄露开发机目录结构。-ldflags-s -w会去掉符号表和调试信息体积立刻小很多代价是后续没法用 Delve 调试生产环境一般可以接受。有一点要提醒ldflags-s -w并不是所有场景都合适。如果你需要在生产容器里排查问题可能需要保留符号表如果你用pprof做性能分析也需要完整符号。体积和可调试性本来就是一种取舍根据项目实际情况选择就好不要为了瘦身而瘦身。3.2 案例二前端项目用 Nginx 托管静态资源前端项目是最容易被单阶段写法坑到的地方。很多人直接把node:20作为基础镜像然后在里面跑一个静态文件服务端。这么做能跑但镜像体积轻轻松松超过 1GB里面全是 npm 依赖、构建缓存、Node 运行时而最终要交付的其实只是一堆静态文件。用多阶段构建之后构建阶段用 Node 负责编译最终阶段切到 Nginx 镜像只保留构建产物FROM node:20 AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . ARG VITE_API_BASE_URLhttp://api.example.com ENV VITE_API_BASE_URL$VITE_API_BASE_URL RUN npm run build FROM nginx:1.27-alpine COPY --frombuilder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80最终镜像通常只有几十 MB。如果你有多套环境可以给每个环境传不同的VITE_API_BASE_URL比如测试环境构建时执行docker build --build-arg VITE_API_BASE_URLhttp://test-api.example.com -t demo/web:test .生产环境传生产地址。这个案例里还有一个很容易被忽略的点npm ci必须在COPY package.json package-lock.json ./之后执行顺序乱了依赖层缓存就全废了。前端项目依赖数量多这里优化带来的时间节省非常可观。另外nginx.conf放哪里、用什么配置都要结合你的路由方式决定特别是用了 history 路由的单页应用需要配置 try_files 回退到 index.html否则刷新页面就 404。3.3 构建参数和目标阶段的高效用法多阶段构建除了最终镜像还有两个日常很常用的玩法。第一个是--target它允许你只构建到某个指定的阶段。比如 Go 项目里想看看 builder 阶段编译出来的产物或者排查某个文件在不在可以这样docker build --target builder -t demo/server:builder . docker run --rm -it demo/server:builder sh这个用法在调试时特别有用。最终阶段报错说文件找不到与其反复猜不如直接进 builder 阶段的容器里看文件到底在哪里。第二个是--output它能把指定阶段的文件系统直接导出到宿主机有时候 CI 里只想拿编译产物而不想保留镜像可以用docker build --target builder --output typelocal,dest./out .这个命令会把 builder 阶段的文件系统内容导出到./out。要注意它导出的是整个阶段文件系统不是/out/server这一个文件如果只想导出单个文件可以用--output typelocal,dest./out,frombuilder结合更精细的目录设计。还有--progressplain会输出完整的原始构建日志排查构建问题时比默认的交互式视图好用得多--no-cache则是当你怀疑缓存误事时的杀手锏强制全量重编。4. 常见问题与排查技巧实录4.1 阶段与文件相关的报错多阶段构建最常见的报错集中在COPY --from。第一种是COPY --frombuilder: no stage or image found这个通常是阶段名拼写错了或者AS后面的大小写不一致。Docker 的COPY --from引用阶段名时是大小写敏感的builder和Builder会被当成两个完全不同的名字。建议所有阶段名统一用小写加连字符比如builder-go、builder-node减少低级错误。第二种是文件找不到COPY --frombuilder /out/server /app: file not found原因通常是构建产物路径和COPY里写的路径对不上。排查思路是先用--target builder把中间阶段构建出来进入容器查看文件真实路径确认产物确实存在后再把路径修正到 Dockerfile 里。这个过程听起来很笨但比我见过的大多数靠猜路径的方法都快得多。4.2 构建缓存不生效与 BuildKit 挂载很多人一开始会把COPY . .放在RUN go mod download之前结果源码一改依赖下载也重新执行。这不是缓存机制失效而是 Dockerfile 的层设计问题。要记住Docker 的缓存是逐层的一旦某一层因为 COPY 的文件变化而失效后面所有层都不再复用。想保依赖缓存就必须把依赖文件单独复制、单独下载最后再复制源码。BuildKit 还提供了更细粒度的缓存方式比如# syntaxdocker/dockerfile:1 FROM golang:1.22 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN --mounttypecache,target/go/pkg/mod go mod download注意第一行必须声明syntaxdocker/dockerfile:1否则部分 BuildKit 语法不被识别。在 Docker Desktop 里 BuildKit 默认是开启的在纯命令行环境下如果没生效可以显式用DOCKER_BUILDKIT1 docker build .开启。日常清理缓存时不要手动跑到/var/lib/docker里删文件直接用docker builder prune -f就好在 Windows 上的 Docker Desktop 里这么做还能避免文件锁带来的莫名问题。4.3 Windows 下 Docker Desktop 环境问题多阶段构建本身不挑平台但如果你在 Windows 机器上连 Docker Desktop 都启动不了后面所有优化都无从谈起。比较典型的报错是启动时提示virtualization support not detected或者类似Docker Desktop failed to start because virtualization is not enabled。这种情况通常和代码无关是宿主机的虚拟化环境没准备好。处理步骤一般是先确认 CPU 虚拟化已经开启可能需要进 BIOS 打开对应开关然后在 Windows 功能里勾选 Hyper-V 和“适用于 Linux 的 Windows 子系统”接着用管理员权限打开 PowerShell执行wsl --install或wsl --update确保 WSL2 的内核组件完整最后在 Docker Desktop 的设置里确认后端是 WSL 2 而不是 Hyper-V 模式。如果还是不行检查一下系统是否有组策略把虚拟化关了或者硬件本身不支持。在 Windows 上写 Dockerfile 还要注意路径习惯问题。容器内是 Linux 文件系统路径区分大小写而 Windows 的路径不区分。我见过有人直接把C:\Users\xxx\project写进 Dockerfile运行起来必错。规范做法是 Dockerfile 里用相对路径或者容器内的标准绝对路径源码复制通过构建上下文来完成别跟宿主机路径混在一起。4.4 常见错误速查表错误现象常见原因解决办法构建时 exit code 1go build报错编译环境缺少依赖或源码本身编译不过先在本地或 builder 阶段容器里编译验证COPY --frombuilder: no stage or image found阶段名拼写错误、大小写不一致检查AS名称统一用小写启动容器报exec user process caused no such file or directory二进制是动态链接或基础镜像缺 libc关闭 CGO 编译或换debian-slim/distroless改了源码但依赖下载仍全量执行Dockerfile 里COPY . .放在了依赖下载前面把包管理文件复制和依赖安装放到源码复制之前Docker Desktop 启动失败提示虚拟化相关错误虚拟化未开启或 WSL2 组件不完整BIOS 开启虚拟化执行wsl --update选择 WSL 2 后端多阶段构建用久了我最大的体会是它逼着你想清楚一个服务的运行边界。以前写 Dockerfile默认思路是“装全一点能跑就行”现在我的固定套路是先写 builder 阶段编译好之后进容器确认产物路径再写 runtime 阶段最终阶段只拷贝真正要运行的文件。这套流程看起来多花了几分钟但后面省的时间远不止这些。依赖单独一层、源码单独一层、产物单独拷贝这个模式基本适用所有语言项目。多阶段构建未必能让构建速度一口气提升多少但它让镜像体积变得可控让依赖不会被无谓的源码变更影响也让每个人拿到手的东西更加接近“运行它真正需要的东西”。