我记得第一次认真面对镜像臃肿这件事,是因为一套前后端服务的生产部署:前端是 Vue 打包出来的静态资源,后端是 Go 写的接口服务,按我当时的“直觉”写了一个 Dockerfile,把源码、依赖、构建工具一股脑全塞进镜像里,最后看到docker image ls的结果时人有点傻眼——1.6GB。拉镜像慢、磁盘占用高、每次构建都要等好几分钟,这些都是实打实的成本。后来我把 Dockerfile 改成多阶段镜像结构,同一个项目最终镜像压到了 30MB 左右,这一下就回不去了。
这篇文章会把 Dockerfile 和多阶段镜像制作这件事从头到尾拆开,包括为什么需要多阶段构建、核心语法怎么理解、我实际项目中怎么拆分阶段,以及在镜像裁剪和缓存加速上踩过的一些坑。无论你是刚开始写 Dockerfile,还是写了很久但始终没把镜像体积压下来,这篇文章应该都能给你一套可以直接抄作业的思路。
1. 我的第一个“巨无霸”镜像:为什么简单服务也能拖出 1GB 级体积
1.1 一步到位的单阶段 Dockerfile,问题出在哪
早期构建镜像时,我最常用的写法就是“一条龙”:直接选择一个功能齐全的基础镜像,把项目拷进去,装依赖、跑构建、设启动命令,完事。以 Node 项目为例,大概长这样:
FROM node:20 WORKDIR /app COPY package.json package-lock.json ./ RUN npm install COPY . . RUN npm run build CMD ["npm", "start"]这一段 Dockerfile 在开发阶段跑得很顺畅,毕竟本地怎么跑,镜像就怎么构建,思路完全一致。但到了生产环境,问题就暴露了:基础镜像本身就有 1GB 左右,再加上 node_modules、业务源码、构建缓存全都被封装进去,一个看似简单的服务,镜像体积轻松破 GB。
更麻烦的是,后续每次构建哪怕只改了一行业务代码,COPY . .之后的所有步骤都会重新执行,依赖安装、编译打包全部重来。项目不大的时候还能忍,项目一旦膨胀,一次构建十分钟起步,CI 排队排得人烦躁。
1.2 问题的本质:构建工具链和运行环境被混在同一个镜像里
后来我认真分析了一下这个镜像里到底装了什么,发现至少有四类东西在生产环境中是完全不需要的:
- 完整的基础系统工具链,比如编译器、make、git、各种开发头文件。构建阶段用得上,运行阶段根本不会调用。
- 开发依赖,比如构建工具、测试框架、打包器。生产环境只需要产物,不需要这些中间件。
- 依赖安装过程中产生的缓存和临时文件,比如 npm 的缓存目录、Go 的模块缓存,全被记录在镜像层里。
- 项目源码里的附加内容,比如
.git目录、测试用例、文档、配置文件模板,本来就不该进入生产镜像。
用生活场景类比就是:你在家做一顿饭,需要买菜、洗菜、切菜、开火、炒菜,最后端上桌的其实只有一盘菜。但是单阶段镜像的做法,相当于把整个厨房连燃气灶带冰箱一起搬到餐桌上,留下中间步骤里所有用不到的东西。多阶段构建的思路则是:备菜在一个厨房完成,炒菜在另一个厨房完成,最后只把做好的那盘菜端上桌,前面两个厨房用完就扔。
1.3 一个常见的错误解法:拆两个 Dockerfile 是不是更好
发现镜像太大之后,我起初想了一个“笨办法”:维护两个 Dockerfile,一个负责构建,一个负责运行。构建用的 Dockerfile 从完整工具链镜像开始,编译出产物;运行用的 Dockerfile 从轻量镜像开始,手动把产物复制进去。两个文件配合一个构建脚本,勉强能工作。
但用了一阵就发现维护成本极高。两个 Dockerfile 之间的阶段衔接全靠脚本,不同步时很容易出现“构建成功但运行报错”的诡异问题。更要命的是,只要改动一次构建逻辑,就得同时改两个文件,漏改一处就完蛋。相比之下,多阶段构建允许你在一个 Dockerfile 内用多个FROM指令创建多个阶段,阶段之间通过COPY --from传递产物,最终镜像只保留最后一个阶段的内容。构建逻辑集中在一处,可读性、可维护性、可复用性都远胜两个文件的方案。
2. 理解多阶段构建的核心机制:FROM AS 和 COPY --from 的配合逻辑
2.1 语法本身并不复杂,核心就三件事
多阶段构建的语法可以用一句话概括:给阶段起名、跨阶段复制、只留最后阶段。先看一个最小可用的例子:
# 阶段一:负责编译 Go 程序 FROM golang:1.22 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /app/server . # 阶段二:负责运行时环境 FROM alpine:3.20 COPY --from=builder /app/server /usr/local/bin/server CMD ["server"]这里只需要理解两个新语法点:
FROM image AS name:给某个阶段起一个逻辑名。后面所有引用都靠这个别名完成。如果不起名字,默认阶段名就是基础镜像的名字,但在多阶段 Dockerfile 里,同一镜像可能出现多次,所以强烈建议每阶段都显式起一个有意义的名字。COPY --from=name source destination:从名为name的阶段里复制文件到当前阶段。--from后面既可以写阶段别名,也可以直接写一个具体的镜像名,比如COPY --from=some/nginx:alpine /usr/share/nginx/html /html,这个用法在某些需要借用其他镜像内容的场景也很有用。
第二个FROM之后,前面的阶段内容不会再出现在最终镜像里。整个docker build的产物只等于最后一个阶段的内容,之前的阶段只是构建过程的中间产物,结束后只作为缓存存在,不会进入最终镜像文件。
2.2 阶段之间不共享文件系统,只共享构建上下文
不少人第一次接触多阶段时会问:阶段之间能不能直接读写对方的文件?答案是不能。每个阶段都是从自己的基础镜像开始的独立文件系统,阶段之间唯一的文件交换通道就是COPY --from。每个阶段能看到的,只有它自己这一段指令执行后的结果。
但有一个容易被忽略的点:所有阶段共享同一个构建上下文。也就是说,你在docker build .时传给 Docker 引擎的那个目录,所有阶段都能访问。这带来一个很重要的实践意义:不同阶段之间如果想要共享某个原始文件,不需要费劲地在阶段间复制来复制去,只要它位于构建上下文目录内,任何阶段都能直接从上下文里获取。
理解这一点之后,你就明白为什么.dockerignore在构建时特别重要了。上下文里装的东西越多,构建准备阶段越慢,也越容易让缓存失效。
2.3 BuildKit 带来的并行构建和细粒度缓存,是现代多阶段的底气
如果你还在用很老的 Docker 版本,多阶段构建虽然能用,但体验和现代环境完全不同。Docker 20.10 以上版本,默认构建引擎已经换成了 BuildKit。BuildKit 给多阶段构建带来的提升,不只是速度更快,更重要的是两个能力:
一是阶段级并行。只要多个阶段之间没有依赖关系,BuildKit 会并行构建它们,而不是一个接一个排队等待。我前面提到的前端打包阶段和后端编译阶段完全独立,它们可以在同一时间里同时执行,在 CI 上能省不少时间。
二是更细粒度的缓存机制。传统构建器以整条指令为单位做缓存,只要前置任何一层发生变化,后面的缓存全部失效。BuildKit 引入了RUN --mount=type=cache这类高级语法,可以把一个临时目录挂载进构建过程,内容保留在构建缓存里,不写入镜像层,也不参与常规缓存失效判定,这操作对依赖安装场景效果立竿见影。
如果你还在用DOCKER_BUILDKIT=0维护老旧构建流程,建议尽快调整。生产环境构建,真的没必要跟缓存和并行过不去。
3. 一套可以直接落地的实战案例:前端打包加后端编译,最终镜像只有 30MB
3.1 一个典型项目的拆分思路
这套实战案例用最常见的组合来演示:一个后台管理系统,前端是 Vue 3 + Vite 构建的静态资源,后端是 Go 写的 HTTP 服务,部署方式采用后端进程直接托管前端静态文件,一个端口跑完整个服务。项目目录结构大致是:
service-app/ ├── frontend/ │ ├── package.json │ ├── package-lock.json │ ├── vite.config.ts │ └── src/ ├── backend/ │ ├── main.go │ ├── go.mod │ └── go.sum └── Dockerfile最终镜像需要包含两部分:后端编译出来的可执行文件,以及前端构建产出的静态资源目录。后端启动时读取静态资源目录并对外提供访问。
整个 Dockerfile 拆成三个阶段:前端构建阶段、后端编译阶段、运行组装阶段。这种拆分方式的好处是,构建和运行对基础镜像的要求彻底分开了。
3.2 阶段一:前端静态资源的构建
前端阶段既不是最终运行的镜像,也不是最后阶段,所以不需要考虑体积、安全、权限,只需要考虑“构建速度”和“构建结果是否正确”。
FROM node:20-alpine AS frontend-builder WORKDIR /build/frontend COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci COPY frontend/ ./ RUN npm run build这一段有两个细节值得展开。
第一个是npm ci而不是npm install。npm ci会严格按照package-lock.json安装依赖,不重新解析版本范围,速度快且可复现。生产环境构建里,可复现比“灵活性”重要得多。如果你还习惯用npm install,建议在 Docker 环境里改成npm ci。
第二个是文件复制顺序。先把package.json和package-lock.json复制进去,执行完依赖安装,再复制剩余源码。这样做是为了充分利用 Docker 的层缓存:只要依赖文件没变,npm ci这层就能命中缓存,业务代码改动不会触发依赖重装。反过来,如果一开始就COPY . .,任何一行代码变动都会让后面的所有步骤全部重新执行。这个原则,在多阶段构建的每个阶段中都同样适用。
3.3 阶段二:后端 Go 服务的静态编译
后端阶段用完整 Go 工具链镜像,目标是编译出一个尽可能精简、可独立运行的二进制文件。
FROM golang:1.22-alpine AS backend-builder WORKDIR /build/backend COPY backend/go.mod backend/go.sum ./ RUN go mod download COPY backend/ ./ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /build/myapp-server .这里有一个关键参数需要理解透彻:CGO_ENABLED=0。它的作用是关闭 CGO,强制生成纯静态的 Go 二进制。这样做的好处是,最终运行镜像可以选择 Alpine、Distroless 甚至 scratch 这类没有 glibc 的轻量基础镜箱,不需要担心动态链接库缺失的问题。
-ldflags="-s -w"是去掉符号表和调试信息,能明显减小二进制体积。但我要提醒一句,-s会同时去除符号表,线上panic时堆栈信息会变得难以解析。我个人的习惯是用-w只去掉 DWARF 调试信息,保留符号表,这样既能减体积,又不至于让线上问题排查变成大海捞针。二选一的话,更推荐-ldflags="-w"。
3.4 阶段三:组装一个单进程、非 root、轻量的生产镜像
前端构建和后端编译结束后,进入最后组装。这一步是决定最终镜像质量的关键阶段。
FROM alpine:3.20 RUN apk add --no-cache ca-certificates tzdata && \ addgroup -S app && adduser -S app -G app COPY --from=backend-builder /build/myapp-server /usr/local/bin/myapp-server COPY --from=frontend-builder /build/frontend/dist /app/static USER app WORKDIR /app EXPOSE 8080 CMD ["myapp-server"]这一段有几件事是必经步骤:
- 安装
ca-certificates,否则后端程序访问外部 HTTPS 服务时,证书校验会失败。 - 安装
tzdata,否则容器内默认时区是 UTC,业务日志时间和本地时间对不上,排查问题的时候会非常痛苦。 - 添加非 root 普通用户
app,然后用USER app切换,避免整个服务以 root 身份运行。这是生产环境最基本的安全底线。
整个镜像最终只包含一个 Alpine 系统、CA 证书、时区数据、一个可执行文件、一个静态资源目录。实际构建下来,镜像大小大约在 20MB 到 35MB 之间,和最初那个 1.6GB 的版本形成鲜明对比。
如果你的后端程序把静态资源用embed.FS直接嵌入了二进制,那还可以省掉COPY --from=frontend-builder这一步,镜像体积还能再压缩十几 MB。但有些场景下,运维希望静态资源可以独立替换,那么保留这个目录反而是更灵活的取舍。
4. 镜像体积和构建速度的进阶把控:缓存、依赖与基础镜像选型
4.1 为什么 Dockerfile 的指令顺序,直接决定缓存命中率
Docker 构建缓存的单位是“层”,每一条指令产生一个镜像层。判断一层缓存是否可复用的依据,是这一层的输入是否和上次构建完全一致。输入包括基础镜像、该指令之前的层的内容、以及指令本身。
基于这个机制,最朴素的缓存优化原则就是:把不常变化的文件放在前面,把经常变化的文件放在后面。对大多数语言项目而言,package-lock.json、go.sum、requirements.txt这类依赖锁定文件,变化频率远低于业务代码。所以把它们单独复制出来执行依赖安装,是收益最高的缓存优化手段。
反过来,如果第一行就COPY . .,只要项目里任何一个文件发生变化,这一层就失效了,后面所有步骤重新执行,等于每次构建都是全新构建。很多 CI 构建越来越慢,根本原因就是 Dockerfile 指令顺序安排得不好。
4.2 BuildKit 的依赖缓存挂载:RUN --mount=type=cache
依赖安装类指令消耗的时间大头在网络下载。传统多阶段构建下,即使依赖文件没变,只要这一层因为其他原因缓存失效,所有依赖就要重新下载。BuildKit 从 Dockerfile 1.4 语法开始支持RUN --mount=type=cache,可以把某个目录挂载为构建缓存目录,内容不会写进镜像层,但会保留在构建缓存里供下次构建复用。
以 npm 为例:
# syntax=docker/dockerfile:1.4 FROM node:20-alpine AS frontend-builder WORKDIR /build/frontend COPY frontend/package*.json ./ RUN --mount=type=cache,target=/root/.npm \ npm ci COPY frontend/ ./ RUN npm run build加了这一行之后,npm 的下载缓存会持久化保存。即使依赖安装这一层因为某些原因重新执行,也不需要重新下载全部依赖包。我在实际项目里的感受是,依赖安装阶段基本能快一半以上。
注意,RUN --mount=type=cache依赖 BuildKit 和较新的 Dockerfile 语法支持,使用前确认基础镜像侧环境兼容。如果报Unknown flag: mount之类错误,先检查 Docker 版本和文件头部是否加了# syntax指令。
4.3 基础镜像怎么选:Alpine、Slim、Distroless 还是 scratch
多阶段构建的最终阶段,是决定镜像体积的关键。这里把常用的轻量基础镜像放在一起对比:
| 镜像类型 | 体积大概水平 | 特点 | 适合场景 |
|---|---|---|---|
| Alpine | 5-8MB | 基于 musl libc,带 apk 包管理,安装软件方便 | 大多数语言运行时,能省体积且方便装工具 |
| Debian slim | 30-80MB | 保留 glibc 和包管理工具,兼容性好 | 依赖 glibc 动态链接的二进制,需要调试工具 |
| Distroless | 几十 MB | 只有运行时库,没有 shell 和包管理器 | 追求极致安全和精简,不需要进容器调试 |
| scratch | 0MB 起步 | 完全空镜像,连 ls 都没有 | 纯静态编译的 Go、Rust 二进制 |
我个人的选择逻辑大概是:如果语言生态依赖动态链接库,优先选对应语言的 slim 版本或 Alpine;如果是 Go 或 Rust 这类能生成纯静态二进制的语言,优先选 scratch 或 Distroless。需要频繁进容器排障的话,再权衡一下是否保留 shell。
前面那个实战案例里选 Alpine,是因为后端程序用CGO_ENABLED=0编译成了静态二进制,同时我还需要安装tzdata和ca-certificates,Alpine 的 apk 安装很方便,体积也不算大。
4.4 .dockerignore 在镜像制作中的比重,远比很多人想象的重要
构建上下文会在构建开始时打包发送给 Docker 引擎。上下文里文件越多,等待时间越长,镜像层越容易混入不该出现的内容。很多人只在 Dockerfile 里写 COPY,却忘记写.dockerignore,结果本地node_modules、.git、dist等巨大目录全被送进了构建上下文。
一个基础的.dockerignore至少应该包含:
.git node_modules dist coverage *.log docker-compose*.yml .DS_Store对多阶段构建而言,这步尤其重要。比如前端构建阶段,如果构建上下文里带着本地node_modules,一旦COPY . .复制了它,镜像的实际体积会异常膨胀,而且这一层缓存会因为 node_modules 的变动频繁失效,构建速度迅速恶化。
5. 多阶段构建中我踩过的坑,以及完整的排错思路
5.1 坑一:COPY --from引用阶段名写错,构建直接中断
有一次我把阶段名从builder改成了backend-builder,但忘记同步后面的COPY --from=builder,构建到那里直接报错,提示看起来很像网络权限问题。实际原因很简单:阶段名没对上。
遇到这类报错,先回 Dockerfile 确认:
--from后面的名字是否和某个FROM xxx AS xxx完全一致;- 阶段名区分大小写,不能有空格或隐藏字符;
- 是否整个阶段都没被执行,导致引用的阶段不存在。
排查时可以单独构建某个阶段验证,用docker build --target frontend-builder -t debug-front .这种方式,只构建指定阶段并查看产物,不用每次从头跑到尾。
5.2 坑二:Alpine 运行动态链接二进制,报 “No such file or directory”
这个问题非常迷惑人。那时候我把后端二进制复制到 Alpine 镜像中,启动容器时报exec /usr/local/bin/myapp-server: No such file or directory。文件明明存在,权限也是可执行的,但系统就是找不到。
实际原因是这个二进制依赖了 glibc,而 Alpine 用的是 musl libc,动态链接库的路径和 ABI 完全不匹配,shell 找不到解释器就把模块报成了“文件不存在”。
解决方案只有两条路:一是编译时用CGO_ENABLED=0生成静态二进制,当场解决;二是改用 Debian slim 这类带 glibc 的运行镜像。
排查时用file /path/to/binary看二进制类型,如果显示dynamically linked,而运行镜像是 Alpine,那基本可以确定就是这个坑。
5.3 坑三:非 root 用户权限不够,服务起不来
我在运行阶段添加非 root 用户后,有一次忘了给工作目录和数据目录分配权限,结果容器启动后程序读取文件报Permission denied。多阶段构建中复制过来的文件,默认属主是 root,普通用户往往只能读不能写,一旦程序需要写缓存目录或者日志文件,立刻出问题。
我的处理方式是在 Dockerfile 里显式设置权限:
RUN mkdir -p /app && chown -R app:app /app USER app或者直接用COPY --chown指定属主:
COPY --chown=app:app --from=backend-builder /build/myapp-server /usr/local/bin/myapp-server这一点在实际项目中特别容易被忽略。如果你在容器里遇到了奇怪的权限报错,优先排查运行用户对工作目录、挂载卷、临时目录是否有完整读写权限。
5.4 坑四:依赖安装缓存无限失效,构建越来越慢
我之前有一次接手别人的 Dockerfile,对方写法是直接把整个项目打进去再安装依赖:
COPY . . RUN npm install功能上跑得通,但任何源码变动都会让npm install重新执行。项目从几个文件涨到几十个模块的时候,一次构建从三分钟慢慢拖到了二十分钟。
修正后的做法就是前面提过的:先复制依赖锁定文件,执行依赖安装,再复制业务代码。我改完之后,第一次构建稍慢,之后每次迭代都稳定在一分钟左右,除非依赖版本发生变化。这个收益在 CI 上体现得最明显,一次发布流程从半小时缩到了几分钟。
所以说,多阶段构建并不是什么高深技术,它的核心价值在于:让构建阶段和运行阶段各自使用最合适的基础镜像,通过COPY --from只传递必要产物,再配合层缓存和基础镜像选型,把镜像体积和构建耗时同时压下来。
最后分享一个我一直在用的小习惯:构建完成之后,用docker image history 镜像名看一眼每一层的大小,找出体积异常的层,再针对那一层做优化。镜像体积控制不是一锤子买卖,每次调整 Dockerfile 之后都看一眼记录,慢慢就会形成直觉。