在 Compose 项目里谈镜像体积,通常不是“小就是好”的问题,而是判断哪些东西只属于构建过程、哪些东西必须留在运行过程。多阶段构建只是把这两个阶段分开,真正要确认的是:拆分后最终阶段是否变干净了,以及 CI 里的构建时间是否还能接受。下面按判断、操作、检查、风险几个角度来整理。
先判断拆分的收益
当构建过程依赖编译器、包管理器或前端工具链,而这些工具在容器运行期完全不需要时,多阶段构建就非常合适。一个直观的判断标准是,构建产物与依赖环境之间的体积差距是否显著。例如,使用Golang或Rust编译静态程序时,构建阶段可能需要下载大量第三方模块,而最终二进制却只有几十MB;前端项目用Node构建,运行阶段却只需要静态文件和一个Web服务器。如果中间产物或临时文件会让镜像膨胀数百MB以上,就应当考虑拆分构建阶段与运行阶段。反之,如果构建过程只是简单复制文件,拆分带来的收益就有限,还可能增加维护负担。
需要注意,这个判断要落到具体项目上。如果项目是 Java + Maven 构建和运行都需要 JRE,并且中间产物不大,那么多阶段构建减少的体积可能只在几十 MB 左右,这时候重点就不是省体积,而是避免 jar 包和临时文件混在一起。可以先在本地把当前镜像的 layer 列出来,再看最终阶段缺少什么。这里不要只看 docker image ls 的总大小,因为镜像的压缩体积和展开体积往往差别很大。
Dockerfile 与 Compose 的配合方式
编写多阶段Dockerfile时,第一阶段使用带完整工具链的基础镜像,命名一个易于理解的别名,比如FROM node:18-alpine AS build。第二阶段切换到精简基础镜像,如alpine或distroless,然后只通过COPY --from=build /app/dist /usr/share/nginx/html这种方式把构建产物复制过来。不要漏掉--from参数,也不要把整个构建目录原样复制,只复制真正需要的产物文件。在Compose文件中,可以通过build.target指定需要构建的阶段,比如在开发环境使用target: develop,在CI或生产环境使用target: production,这样既能复用Dockerfile,又能避免每次都构建无关阶段。
实际操作时,build.target 需要 Dockerfile 里存在对应的阶段名,Compose 只是把目标阶段名透传给 build 命令。若开发环境需要安装调试工具,可以在 Dockerfile 里加一个 develop 阶段,并在 Compose 的开发配置里指定 target: develop。生产阶段建议使用单独的阶段名,并在 CI 参数中覆盖 target,而不是直接改 Dockerfile。
services:
web:
build:
context: .
target: production运行阶段选择精简镜像时,要先确认二进制是否依赖动态链接库。比如基于 distroless 或 alpine 时,第三方 C 库、CA 证书和时区数据都需要显式拷贝,否则容器启动时才会报错。不要只为了体积硬换 runtime。
按层检查,而不是只盯总大小
镜像构建完成后,先执行docker image ls查看大小,再用docker history --no-trunc查看每一层增加的体积,这样能定位到底是哪一层引入了不必要的内容。不过history只显示层的定义,对于文件删除不一定准确,因此更推荐使用dive这类工具,它能把每个UUID层中的文件变化和剩余空间直观列出来,帮助发现临时缓存或未清理的包管理器索引。对比优化效果时,要保证两次构建的基础镜像和构建环境一致,否则大小差异可能来自基础镜像更新,而不是多阶段构建本身。另外,可以在CI中设置镜像大小阈值,一旦超出就通知维护者,但阈值需要根据项目实际情况调整,避免频繁误报。
这节有一个常见误区:docker history 显示的是层创建命令的大小,如果某个 RUN 里后一条命令删除了前一条命令的文件,历史记录仍然会显示两条 layer 的大小,只有最终 image size 才能体现删除效果。所以真正要看的是两个时间点之间的 sum,而不是单独一层的“大小”。dive 更适合定位“为什么这条 RUN 之后镜像还这么大”,但它需要连接到 docker daemon,在 CI 里使用时要确认权限。
容易忽略的风险边界
多阶段构建最常见的边界问题,其实是基础镜像的 libc 不一致。构建阶段用 Debian 等 glibc 系统,运行阶段切换到 alpine 的 musl,最后执行二进制会直接提示 No such file or directory,而不是“缺少 libc”。稳妥做法是让运行阶段与构建阶段使用同一套 libc 体系;如果追求最小镜像,需要在构建阶段就考虑静态编译,并且只 COPY 可执行文件和必要的配置。
另一个边界是构建缓存。Compose 调用 build 时也会使用 BuildKit 的缓存,若 Dockerfile 里先 COPY 整个源码再执行 npm install 或 go mod download,源码一改动,安装依赖的缓存层就会失效,后续构建会反复下载。建议先 COPY package-lock.json 或 go.mod 这类依赖清单文件,执行完安装再 COPY 源码。
常见坑和收尾
常见坑是“多阶段了,但最终阶段没有切换基础镜像”,结果只是把构建产物复制回同一个完整系统,体积几乎没有变化。建议最终阶段使用 alpine、distroless 或 scratch,并手动确认动态库和证书。另一个坑是 COPY 时把源码、node_modules、日志一起复制过去,既增大镜像又可能带出敏感信息,所以 COPY 路径要精确到 dist、target、bin 这类产物目录。
最后提醒一句:多阶段构建不会把中间产物自动清理掉,它们仍然留在本机 BuildKit 缓存里。定期执行 docker system prune 或 docker builder prune 可以释放空间,但 CI 里不要随意执行,否则会丢掉所有缓存,反而让构建变慢。优化镜像体积需要和构建时长一起观察,不要在完全没有层分析的情况下盲目删除 RUN 步骤。