1. 为什么你的镜像动辄几个G?先聊聊多阶段构建的价值
作为一个常年跟 Docker 打交道的人,我见过太多这样的场景:新同事拉下一个 Java 后端项目,docker build构建完一看,镜像 1.2GB。问他为什么这么大,他也说不清,反正基础镜像就用了openjdk:latest,一顿操作下来整个镜像臃肿得不行,推到仓库慢,拉下来更慢,磁盘直接被占满。
其实这个问题,用 Docker 多阶段构建就能很好地解决。多阶段构建是 Docker 17.05 版本引入的特性,核心思路特别朴素:一个 Dockerfile 里可以有多个FROM,每个FROM是一个独立的构建阶段,前面阶段产出的文件可以只挑有用的部分复制到后面阶段,最终镜像只保留最后一段的内容。编译工具链、依赖源码、中间缓存这些“只参与构建、不参与运行”的东西,可以全部留在中间阶段,不污染最终镜像。
这篇文章我不打算写成像官方文档那样条目化,而是想结合我自己在项目里从“镜像怎么这么大”到“极其熟练地把镜像从 GB 级压到几十 MB”的真实过程,把多阶段构建是什么、怎么用好、有哪些坑、怎么排查,全部串一遍。无论你是刚开始玩 Docker,还是已经在生产环境里维护镜像,都可以从中找到用得上的东西。
写这篇内容之前,我还顺手看了一眼大家最近都在搜什么。很多人卡在 Docker 安装、镜像下载慢、容器启动失败这些最基础的环节,这些我也经历过。所以后面会穿插一些实用的避坑提示,比如镜像源怎么配、构建缓存怎么命中、BuildKit 怎么开,尽量让每个问题都有对应的落地解法。
2. 多阶段构建的正确打开方式:一个 Go 项目的对比实践
2.1 先看单阶段构建是怎么把镜像搞大的
假设你有一个 Go 编写的 HTTP 服务,代码项目结构大概是这样的:
myapp/ main.go go.mod go.sum如果从来没接触过多阶段构建,很多人会这样写 Dockerfile:
FROM golang:1.21 WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o myapp . CMD ["./myapp"]这段 Dockerfile 在功能上没毛病,golang:1.21这个镜像包含了完整的 Go 编译工具链、git、各种系统库,所以你本地能编译,容器里也能编译。但问题就来了:golang:1.21镜像本身就接近 800MB,你编译出来的二进制包可能只有 30MB,可最终镜像把编译工具链也全装进去了。如果项目还依赖 CGO 或者额外的系统库,体积会直接往 2GB 以上走。
这些构建产物在运行阶段真的需要吗?完全不需要。真正跑容器时,只需要那个 30MB 的二进制文件,其余的编译器、包管理工具、缓存、系统头文件全是垃圾负担。这就是单阶段构建最大的弊端:把构建环境当作运行环境用,最终镜像体积虚高。
这在本地开发时感受还不太明显,一旦进了团队协作或者生产环境,问题就放大了。镜像推到私有仓库要等,发布平台拉镜像要等,多个服务同时更新时磁盘被撑爆也是常有的事。
2.2 用多阶段构建把镜像从 800MB 降到 30MB
多阶段构建的写法其实很简单,就是在同一个 Dockerfile 里写多个FROM,每个FROM可以用AS起一个名字。先看改造后的完整 Dockerfile:
# 第一阶段:编译 FROM golang:1.21 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o myapp . # 第二阶段:运行 FROM alpine:3.19 WORKDIR /app COPY --from=builder /app/myapp . EXPOSE 8080 CMD ["./myapp"]这个 Dockerfile 里有两个阶段:
- 第一阶段叫
builder,基于golang:1.21,负责把源码编译成可执行文件; - 第二阶段基于
alpine:3.19,一个只有几 MB 的极简 Linux 镜像,只负责运行。
第二个阶段通过COPY --from=builder /app/myapp .把第一阶段编译出的myapp复制过来,整个最终镜像里既没有 Go 编译器,也没有源代码,只有一个静态编译的二进制文件。最终的镜像体积通常能从 800MB 左右降到 20-30MB,压缩后甚至不到 10MB。
这段 Dockerfile 解决了两个核心问题:一是构建环境与运行环境彻底解耦,二是镜像里只保留运行时需要的内容。这也是多阶段构建最核心的语义:每个阶段都是独立的,阶段之间的交互只发生在显式声明的地方。
注意:
COPY --from=builder里的builder是第一阶段AS后面定义的别名,也可以用数字序号从 0 开始计数,比如COPY --from=0。但为了可读性,强烈建议用有名阶段,不然等 Dockerfile 写长了,根本不知道--from=0到底指的是哪个阶段。
2.3 运行阶段的细节不能只看体积
镜像变小只是一个方面,多阶段构建还有一个容易忽略的优势:运行阶段的系统环境变得更可控。
golang:1.21是基于 Debian 的完整系统,里面有什么软件你未必完全清楚。而alpine:3.19是一个极简系统,里面只有最基本的工具和库,攻破面天然小很多。同时,最终镜像里的依赖更少,意味着漏洞出现的概率也更低,这对生产环境来说是很重要的安全收益。
不过,alpine 也有一个需要留意的点:它用的是 musl libc,跟常见的 Debian/Ubuntu 用的 glibc 不是同一套。如果你的二进制文件是动态链接的,直接丢进 alpine 里可能跑不起来,会报No such file or directory或者类似加载器找不到的错误。
解决方式无非两种:一是像上面例子一样,编译时设置CGO_ENABLED=0,让 Go 直接编出纯静态的二进制,完全不依赖系统库;二是运行阶段改用distroless镜像或debian:stable-slim,这样能和 glibc 动态库保持兼容。后面我在常见问题部分会再展开讲这个坑。
3. 一个真实案例:Node.js 服务镜像瘦身全过程
3.1 前端工程化和 Node 服务的镜像痛点
Go 这种编译型语言做多阶段构建非常直观,很多教程也都拿 Go 举例。不过实际工作中,Node.js 和 Python 这类解释型语言才是大多数团队的主力,它们的多阶段构建思路略微不同,但效果同样显著。
我一个朋友的团队做过一个基于 Express 的 Node.js 服务,最初 Dockerfile 是这样写的:
FROM node:18 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . EXPOSE 3000 CMD ["node", "server.js"]这个镜像构建完大概 1.1GB。原因很直接:node:18完整镜像里包含了 npm 源码、构建工具链、全局文档,还有为了兼容各种 Node 模块而预装的编译器和 Python。再加上npm install时安装的全部是带开发依赖的包,很多只在测试阶段用到的库也进了最终镜像。
这种镜像不仅构建慢,部署也痛苦。每次升级发布,传输 1GB 多的镜像,在弱网环境下基本就是灾难。
3.2 分阶段拆解:依赖安装和生产部署分离
Node.js 是多阶段构建里非常典型的案例,我建议按下面这个思路拆分:
# 第一阶段:安装依赖并构建 FROM node:18 AS build WORKDIR /app COPY package*.json ./ # 创建一个临时目录,在生产模式下安装依赖 RUN mkdir -p /tmp/app && \ cp package*.json /tmp/app/ && \ cd /tmp/app && \ npm ci --omit=dev # 复制业务源码 COPY . . # 如果项目有构建步骤,比如 TypeScript 编译、前端打包,在这里做 # RUN npm run build # 第二阶段:生产运行环境 FROM node:18-alpine AS runtime ENV NODE_ENV=production WORKDIR /app # 只把生产依赖复制过来 COPY --from=build /tmp/app/node_modules ./node_modules # 复制业务代码,可以根据需要在构建阶段把关键代码过滤后再复制 COPY --from=build /app/server.js . COPY --from=build /app/src ./src # 如果项目有 dist 目录或其他产物,也在这里复制 EXPOSE 3000 USER node CMD ["node", "server.js"]这里面的关键动作是什么?我一个个说。
第一,第一阶段用了npm ci --omit=dev,只装生产环境需要的依赖,不装 devDependencies。很多人写 Dockerfile 时习惯npm install,这会把测试、构建相关的依赖全装进来。npm ci是专门针对 CI/CD 场景的命令,会严格按照package-lock.json安装,比npm install更稳定也更快。
第二,依赖安装在临时目录/tmp/app,再把node_modules复制到最终镜像。这样做的原因是:如果你直接在工作目录安装依赖,然后COPY . .,源码目录里的node_modules可能已经被本地或者其他方式污染了。在一个干净的临时目录里安装,出来的依赖树是纯的。
第三,运行阶段用了node:18-alpine,一个精简的 Node 运行时,没有 npm、没有编译器,体积小得多。最终镜像从 1.1GB 降到了 160MB 左右,压缩后更小。
如果想进一步瘦身,还可以考虑把 Node 服务做成编译产物后跑在 distroless 里。不过这一步对 Node 项目来说属于进阶操作,通常团队用 alpine 版本就够了,没必要为了追求极限体积增加排查难度。
3.3 实测对比和运行验证
改造完成后,我用同样的代码分别构建两个版本,实测数据大致是这样的:
| 镜像 | 构建方式 | 镜像体积 | 压缩后体积 | 启动时间 |
|---|---|---|---|---|
| 单阶段 node:18 全量依赖 | 单阶段 | 约 1.1GB | 约 400MB | 约 1.5s |
| 多阶段 node:18-alpine 生产依赖 | 多阶段 | 约 160MB | 约 60MB | 约 1.2s |
体积差距接近 7 倍,启动时间基本没变化,但拉取和推送镜像的时间缩短了非常明显。第二版发布时,镜像从仓库拉取到集群只需要几秒到十几秒,而以前可能要等几分钟。
这里要特别提醒一个容易踩的坑:如果第一阶段运行了npm run build之类的构建命令,生成的产物一定要通过COPY --from=build显式复制到运行阶段,不要指望构建阶段产生的文件自动带过来。多阶段构建中每一个阶段都是独立文件系统,阶段结束不代表文件保存,只有被显式复制的文件才会进入后续阶段。
4. BuildKit 加持下,多阶段构建还能更高效
4.1 开启 BuildKit:默认它就更好用
Docker 多阶段构建从 17.05 开始可用,但真正让多阶段构建能力实现飞跃的,是后来引入的 BuildKit 构建引擎。BuildKit 是 Docker 官方的下一代构建系统,你现在的 Docker Desktop 或新版 Docker Engine 其实已经默认在用了,只不过很多人没有主动感知。
BuildKit 带来的几项关键能力,我列一下实用性最高的几个:
- 更好的构建缓存复用机制,多阶段构建中每个阶段都能独立缓存;
- 支持
--mount=type=cache,可以为依赖目录设置专属缓存,避免每次都重复下载; - 支持
--mount=type=bind,可以把源码只读挂载进构建环境,而不是一层层COPY复制; - 支持构建时密钥和 SSH agent 转发,构建时可以从私有仓库拉依赖而不需要把密钥写进镜像。
如果你用的是旧版 Docker,可以在构建前设置环境变量:
DOCKER_BUILDKIT=1 docker build -t myapp:latest .新版 Docker 默认已经启用,不需要额外配置。
4.2 用缓存挂载把依赖下载时间压缩一半以上
多阶段构建虽然能大幅缩小最终镜像,但如果每个阶段每次构建都重新下载依赖,构建时长就会拖得很长。举个实际例子,一个 Java 项目的 Maven 依赖可能有 200MB,每次从头下都会让人崩溃。
BuildKit 的缓存挂载功能就是来解决这个问题的。在 Maven 或 Gradle 项目中,这样写:
FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app COPY pom.xml . RUN --mount=type=cache,target=/root/.m2 mvn dependency:go-offline COPY src ./src RUN --mount=type=cache,target=/root/.m2 mvn package -DskipTests FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]关键在RUN --mount=type=cache,target=/root/.m2这一句。第一次构建时,Maven 会把下载的依赖写到/root/.m2这个临时挂载点;第二次构建时,BuildKit 会找到之前缓存的内容直接复用,跳过重复下载。这个挂载的缓存只在构建过程中存在,不会进入最终镜像层。对于 Maven 项目,这一条就能把大部分构建时间从 5-8 分钟压到 1 分钟以内。
Node.js 项目同理,可以把~/.npm挂载为缓存:
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev4.3 利用阶段目标调试中间步骤
多阶段构建在实际调试中还有一个很好用的技巧:用--target参数指定只构建到某个阶段。
比如服务跑起来报错了,你想进容器里看看编译阶段生成的中间文件,不需要把整个镜像构建完。直接:
docker build --target builder -t myapp:debug .这样 Docker 只会构建到builder阶段,你可以基于myapp:debug这个镜像启动容器,进去查看源码、依赖、编译产物等等。再配合docker run -it --entrypoint sh myapp:debug,排查问题要方便很多。
这个技巧在优化 Dockerfile 时特别有用。你想验证某个安装命令是否成功、某个目录有没有生成预期文件,先构建到对应阶段,发现问题后只需要修改该阶段之后的命令重新构建,不需要从头跑完全过程。
5. 多阶段构建中绕不开的常见问题和排查技巧
5.1 问题速查表
我根据自己踩过的坑和帮别人排查时遇到的典型案例,整理了一张速查表。
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
构建时提示COPY --from=xxx失败,找不到阶段 | 阶段名拼写错误或该阶段尚未定义 | 检查 Dockerfile 中AS后面的名称是否一致,注意大小写 |
| 最终镜像里文件缺失 | 忘记写COPY --from=build复制文件 | 多阶段中每个阶段文件系统独立,必须显式复制产物 |
alpine 里运行二进制报No such file or directory | 二进制是动态链接的,alpine 的 musl 找不到 glibc 加载器 | 编译时设置CGO_ENABLED=0,或改用debian:stable-slim/distroless作为运行镜像 |
| 容器运行时间显示 UTC,比北京时间慢 8 小时 | alpine/精简镜像没有配置时区数据 | 在运行阶段安装tzdata,或者通过环境变量TZ=Asia/Shanghai配合系统时区文件解决 |
| 以 root 用户运行容器,存在安全风险 | 运行阶段没有创建普通用户 | 在运行阶段RUN addgroup -S app && adduser -S app -G app,然后USER app |
| 每次构建都重新下载依赖,构建很慢 | 没有利用层缓存或 BuildKit 缓存挂载 | 把pom.xml/package*.json等依赖清单单独COPY,再执行依赖安装,最后才复制源码 |
| 构建上下文太大,构建很慢 | 源码目录包含node_modules、.git、临时文件等没有排除 | 合理配置.dockerignore,把不需要的文件全部排除掉 |
5.2 最容易踩的三个隐蔽坑
上面表格里的问题都比较明确,我再单独拎出三个隐蔽坑,因为它们排查起来相当费劲。
第一个坑是动态链接库问题。我帮人排查过一个 Go 服务,本地跑得非常好,docker build也没报错,但容器一启动就报错,提示找不到libpcap.so.1之类的动态库。原因就是这个程序不是静态编译的,它在运行阶段依赖某些系统库,而 alpine 里根本没有这些库。排查方法很简单,在运行阶段临时进入容器,用ldd ./myapp查看动态库依赖,缺什么装什么,或者干脆回到编译阶段把 CGO 关掉做静态编译。
第二个坑是时区。默认情况下 alpine 镜像使用 UTC 时间,很多应用日志打印出来比北京时间慢 8 小时。问题的根因是缺少/usr/share/zoneinfo数据。可以在运行阶段加一句:
RUN apk add --no-cache tzdata \ && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && echo "Asia/Shanghai" > /etc/timezone \ && apk del tzdata这里先安装 tzdata,复制完时区文件后再删除它,是为了避免把额外的数据留在最终镜像里。有些人的做法是在启动命令里设置TZ=Asia/Shanghai环境变量,实测在某些极简镜像里并不生效,因为镜像里根本没有时区数据库,所以复制文件才是更稳妥的方案。
第三个坑是默认用户是 root。很多 Dockerfile 从头到尾没有写过USER指令,容器内应用一直以 root 身份运行。这在开发环境可能感受不到风险,但生产环境一旦有漏洞被利用,权限就是最高级别。多阶段构建的最终阶段建议创建并切换到普通用户,Go 二进制可以这么做:
FROM alpine:3.19 RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app COPY --from=builder /app/myapp . USER appuser CMD ["./myapp"]addgroup和adduser是 alpine 里添加用户组的命令,Debian 系镜像则用groupadd和useradd。要求应用监听 80 端口时需要注意切换用户后没有权限绑定 80 以下端口,通常改成监听 8080 或者配置端口转发。
5.3 配合镜像加速源,解决下载慢的困扰
多阶段构建本身有个前置条件:你得先把基础的编译镜像和运行镜像拉下来。很多新手卡在这一步,就是因为 Docker 默认从 Docker Hub 拉取镜像,国内访问时快时慢,甚至经常超时。
这个问题的解法是配置镜像加速源。以 Windows 上的 Docker Desktop 为例,在设置里的 Docker Engine 配置文件中添加 registry-mirrors 即可。Linux 上则直接修改/etc/docker/daemon.json:
{ "registry-mirrors": [ "https://docker.m.daocloud.io", "https://dockerproxy.com" ] }修改完后重启 Docker 服务。需要注意的是,镜像源可用的稳定性会有波动,如果某个源拉不动了,可以换一个试试。这一步不影响后面多阶段构建的写法,但能明显改善构建体验。
提醒:配置镜像源和编写多阶段构建是两个维度的事情。镜像源解决的是镜像下载通道的速度问题,多阶段构建解决的是镜像本身内容臃肿的问题,两者叠加效果最佳。一个让你拉得快,一个让你拉得小。
6. 多阶段构建在真实项目里的进阶玩法
6.1 不只有 Go 和 Node,Java 和 Python 同样适用
很多教程把多阶段构建写成“编译型语言专用”,其实解释型语言也同样适用。Java 项目里常见的问题是运行镜像里残留全套 JDK 编译工具,实际上生产环境只需要 JRE。多阶段构建可以直接做到这一点,第一阶段用 JDK 镜像编译或打包,第二阶段用 JRE 镜像运行。
来看一个简化版的 Spring Boot 项目 Dockerfile:
# 阶段一:使用 Maven 和 JDK 进行打包 FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests # 阶段二:只需要 JRE 运行环境 FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]从一个完整的 JDK 镜像切到 JRE 镜像,体积从 800MB 级别直接降到 200MB 左右。
Python 项目同理,第一阶段安装依赖到指定目录,第二阶段把这个目录复制过来:
FROM python:3.12 AS builder WORKDIR /app COPY requirements.txt . RUN pip install --prefix=/install -r requirements.txt FROM python:3.12-slim WORKDIR /app COPY --from=builder /install /usr/local COPY . . CMD ["python", "app.py"]这里用pip install --prefix=/install把依赖装到一个独立的目录,运行时阶段只需要把/install复制进去,/usr/local这个默认路径会在sys.path的搜索范围内,所以包能正常被找到。这个做法也能避免把pip的缓存目录一并带进镜像。
6.2 使用 distroless 镜像实现极致精简
如果追求极致的镜像体积和安全性,Google 的 distroless 镜像是非常值得尝试的方案。distroless 镜像里面没有任何 shell、包管理器或者多余命令,只有一个裸的操作系统运行时环境,用途就是单一地跑你的应用。
Go 服务配合 distroless 可以这样写:
FROM golang:1.21 AS build WORKDIR /app COPY . . RUN CGO_ENABLED=0 go build -o myapp . FROM gcr.io/distroless/static-debian12 WORKDIR /app COPY --from=build /app/myapp . USER nonroot ENTRYPOINT ["./myapp"]这样构建出来的镜像几乎只剩一个二进制文件,体积大概 15MB 左右。但要注意,distroless 镜像里没有 shell,也没办法用docker exec进容器执行任何调试命令。日志只能通过标准输出查看,排错手段极其有限。生产环境用 distroless 前,建议先确保你的日志系统、监控系统都已经规范到位,不然真正出问题时可能会抓瞎。
如果是 Java 或 Node 项目,对应有gcr.io/distroless/java17-debian12和gcr.io/distroless/nodejs18-debian12这样的现成镜像,按需选用就行。
6.3 多架构镜像构建:一次构建,多平台运行
现在 ARM 架构的设备越来越多,很多团队需要同一个镜像同时跑在 x86 和 ARM 的服务器上。多阶段构建和 Buildx 结合,可以很轻松地构建多平台镜像:
docker buildx build \ --platform linux/amd64,linux/arm64 \ -t your-registry.com/team/myapp:latest \ --push .Buildx 会在构建时自动处理平台差异,前提是你的 Dockerfile 中没有写入只在某个架构下有效的命令。比如安装某个软件包时,Debian 系镜像的包名在不同架构下可能不一样,需要多加留意。多平台构建会一次性把多个平台的镜像打包推送,对于统一发布流程来说非常有用。
我个人的经验是:先在本地用单平台构建验证业务功能,确认没问题后再执行多平台构建和推送。因为多平台构建的速度通常比单平台慢不少,每次都等完整构建太浪费时间。
7. 写在最后:我实际使用中的一些体会
多阶段构建这个功能,初看只是 Dockerfile 的一种写法,真正用起来会发现它带来的思维方式转变挺重要的。把“构建环境”和“运行环境”当作两个独立的事物去设计,你自然就会开始思考最终镜像里真正需要的是什么,哪些只是过程中的临时产物。这种思考习惯,对一个服务化架构的长期维护是有益的。
我自己的习惯是:每个新项目启动时就写好两阶段的 Dockerfile,运行阶段优先选 alpine 或 slim,依赖安装严格区分生产和开发,编译型语言尽量做静态编译。这样一开始镜像体积就控制在合理范围内,后面就不会出现“这个镜像怎么这么大了”的返工。
另外再分享一个小技巧:构建完镜像后,可以用docker history 镜像名查看每一层的大小,定位到底哪一层占了空间。多阶段构建虽然能解决绝大多数体积问题,但如果某个阶段里误执行了apt-get install后又没清理缓存,镜像体积还是会悄悄涨上去。清洗工作放在同一个 RUN 指令里完成,是控制层大小的关键。
Docker 的官方文档永远是最好的参考资料,但真正把这些特性用得行云流水,还是得靠一次次构建、排查、调整换来的经验。希望这篇实践指南能帮你少走一些弯路,把你的镜像体积和构建效率都优化到让你满意的程度。