
镜像体积比应用代码大 20 倍问题不在代码在构建方式。我接手过不少历史项目最常见的一种情况是业务代码才几十 MB打完包推送到私有仓库拉下来一看1.2GB。运维抱怨部署慢存储成本嗖嗖涨安全扫描报告里高危漏洞几百个一查全是构建工具链和系统依赖带来的。那篇文章要说的核心就一句话多阶段构建能把生产镜像体积砍掉 90%。这个结论不是理论推演是我在一堆真实项目里反复验证过的。本文我会从 Docker 镜像的存储机制说起拆解多阶段构建的原理再用完整实例演示如何改造一个典型的 Node.js 项目最后聊一些生产环境中的隐藏问题。1. 镜像体积膨胀的根源一层层叠加的构建垃圾想理解多阶段构建为什么有效得先搞明白 Docker 镜像是怎么把体积撑大的。1.1 镜像分层的存储机制决定了“删不掉”的中间产物Docker 镜像是分层存储的。每一行RUN、COPY、ADD指令都会生成一个新的只读层。哪怕你在后续层里用rm -rf删掉了上一层的文件被删除的内容依然保留在上一层里物理空间并不会释放。最终镜像的体积是所有层的累加。用一个生活化的类比分层就像是叠盘子。你第 1 层放了台 Build 工具第 2 层放了依赖第 3 层放了应用代码最后一层你不想要工具了把它“删除”了——但删除操作只是最上面盘子写上“此处已删除”下面那层盘子里仍然实实在在装着那套工具。收盘子的人最后端走的还是那一摞完整的盘子一个都没少。传统 Dockerfile 写法恰恰是这种模式的典型受害者。以 Node.js 项目为例FROM node:20 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build CMD [node, dist/index.js]这一套流程跑下来最终镜像里包含了什么完整的 Node.js 运行时开发版npm 包管理器的全部源码和依赖node_modules里所有开发和生产依赖可能上千个包构建产物比如 TypeScript 编译后的 JS项目源码各类临时文件、日志、包管理器缓存其中真正运行时需要的只有node_modules里的生产依赖和dist里的构建产物。但其余所有“没用的东西”全都因为分层机制留在了镜像里体积自然爆炸。1.2 构建工具链是最大的伪装者更隐蔽的问题是构建工具链。很多语言的项目构建阶段和运行阶段需要的环境完全不同。编译 Go需要 Go 工具链、C 编译器、头文件但编译出的二进制运行只需要系统库编译 C/C需要 gcc、make、cmake但运行只需要动态库前端项目需要 node、npm、webpack但运行时只需要静态资源和轻量静态服务器或者 node 进程传统 Dockerfile 把构建环境和运行环境糅合在同一个镜像里等于你出差带了整套工具箱但实际只需要一把螺丝刀。工具链本身可能就要占几百 MB再加上各种编译缓存、下载缓存体积想不大都不可能。多阶段构建的解决思路用一句话说就是构建在一个临时环境里完成只把需要的产物复制到最终镜像中。2. 多阶段构建的工作原理用完即弃的临时工作区多阶段构建并不是 Docker 的特别高深功能它在 Docker 17.05 之后就正式支持了。核心机制就两个关键字FROM ... AS和COPY --from。2.1 基语法拆解两个 FROM 和一次精准复制一个典型的多阶段构建 Dockerfile 长这样# 阶段一构建环境 FROM node:20-alpine AS build-stage WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 阶段二运行环境 FROM node:20-alpine AS production-stage WORKDIR /app ENV NODE_ENVproduction COPY --frombuild-stage /app/dist ./dist COPY package*.json ./ RUN npm ci --omitdev EXPOSE 3000 CMD [node, dist/index.js]拆开看第一个FROM node:20-alpine AS build-stage启动一个完整含 npm、编译工具的构建环境全部依赖和源代码都装进去执行构建脚本所有临时产物留在这个阶段。第二个FROM node:20-alpine AS production-stage重新启动一个干净环境只安装了 Node 运行时基础镜像没有多余的包管理器工具、没有源码、没有构建缓存。COPY --frombuild-stage这一步是精华所在。它可以直接从上一个阶段的文件系统里复制文件而不是从宿主机上复制。这意味着构建阶段产生了什么、需要带走的只有/app/dist和package*.json。其他所有中间内容都留在了构建阶段的那个一次性容器里构建结束阶段容器被丢弃垃圾跟着消失。为什么不在最后阶段重新npm install因为npm ci --omitdev只安装生产依赖。这个命令在最后阶段执行生成的node_modules是纯生产环境依赖体积比开发环境小得多而且不会被源代码污染。2.2 构建缓存和阶段的独立性很多刚接触多阶段构建的人有一个误解多个阶段是不是并行执行的不是。Docker 会按顺序构建各个阶段后一个阶段依赖前一个阶段输出的文件。但阶段之间是隔离的每个阶段都拥有独立的文件系统、独立的环境变量、独立的基础镜像。这个独立性的价值在于你可以为不同阶段选择完全不同的基础镜像。比如构建阶段用完整的node:20带编译工具运行阶段用alpine甚至distroless版本。阶段间互相不干扰构建环境需要的“脏东西”全部局限在临时空间里。缓存方面Docker 会缓存每个阶段的每一层。只要 Dockerfile 某一行之前的指令和文件没有变化就会复用缓存。这也是为什么上面示例中先执行COPY package*.json再RUN npm ci——package-lock.json不常变这一层缓存可以让绝大多数构建秒级完成。如果先COPY . .再npm install那么源文件任何一个字节变化npm install的缓存就被完全清空每次构建都得重新安装所有依赖。多阶段构建真正做的事情是把“怎么构建”和“怎么运行”这两个问题解耦了。你完全可以用开发环境里最顺手的方式构建然后只把干净的产物交给生产环境。3. 实战改造案例从 1.2GB 到 130MB理论说多了容易飘直接上实操。我用一个典型 Node.js TypeScript 项目来做改造完整记录改造前后的体积变化和每一步操作。3.1 传统 Dockerfile 的典型问题先看一个看起来“没毛病”的传统 DockerfileFROM node:20 WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build EXPOSE 3000 CMD [node, dist/index.js]使用 Node 20 官方完整镜像构建完成后查看体积$ docker images | grep myapp myapp latest 1.2GB1.2GB 里有什么用docker history可以分析每一层占用的空间$ docker history myapp你会发现最大的几层几乎全部集中在npm install和npm run build上。源码中实际运行的dist目录可能就十来兆。3.2 多阶段构建的具体改造第一步准备项目。假设项目结构是myapp/ ├── dist/ ├── src/ ├── node_modules/ ├── package.json ├── package-lock.json └── tsconfig.json第二步编写多阶段 Dockerfile为了对比效果我分为三个阶段依赖安装阶段、构建阶段、运行阶段。依赖安装独立出来是为了最大化利用 Docker 层缓存。# 阶段 1安装依赖 FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci --onlyproduction # 阶段 2构建 FROM node:20-alpine AS build WORKDIR /app COPY --fromdeps /app/node_modules ./node_modules COPY . . RUN npm run build # 阶段 3生产运行 FROM node:20-alpine AS production WORKDIR /app ENV NODE_ENVproduction COPY --fromdeps /app/node_modules ./node_modules COPY --frombuild /app/dist ./dist EXPOSE 3000 CMD [node, dist/index.js]这个版本与 2.1 节的基础版有什么不同我把依赖安装抽到了独立阶段deps。好处是build阶段可以用--fromdeps获取依赖production阶段也能用--fromdeps获取同一份node_modules。这样依赖只需要安装一次也保持了没有把开发依赖混进最终镜像。构建完成后重新查看镜像体积$ docker build -t myapp:multi-stage . $ docker images | grep myapp myapp multi-stage 153MB myapp latest 1.2GB体积从 1.2GB 降到 153MB大约是原来的 12.7%打了接近 87% 的折扣。如果再用上 alpine 基础镜像原本已经是 alpine已经压到 153MB。3.3 还能更小甚至不需要 Node 运行时如果项目本身的构建产物是纯静态资源比如前端 Vue/React 项目构建出的 HTML/CSS/JS或者你是用 esbuild 打包成单文件的那甚至可以直接用nginx:alpine作为运行环境或者直接用scratch空镜像。前端静态资源示例FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine AS production COPY --frombuild /app/dist /usr/share/nginx/html EXPOSE 80如果产物是 Go 编译出的静态二进制可以直接放 scratchFROM golang:1.22-alpine AS build WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 go build -ldflags-s -w -o server . FROM scratch AS production COPY --frombuild /app/server /server EXPOSE 8080 CMD [/server]这种极简镜像只有几 MB连 shell 都没有攻破后连命令都执行不了安全性也顺带提升了。不过用 scratch 也有代价没有 shell、没有 curl、没有任何调试工具排查问题需要依赖宿主机的docker cp和docker exec。所以是否用 scratch取决于团队运维习惯和应用排查需求。补一句体积数据。同样一个 Go HTTP 服务传统镜像约 300MB 以上用多阶段构建 scratch最终镜像约 7MB。这不是夸张的宣传是静态编译加空基础镜像的天然优势。4. 再往深一步优化缓存、构建标签与安全收缩多阶段构建只是瘦身的第一步生产环境里真正要让镜像又快又小又安全还有一连串细节要处理。4.1 缓存失效策略依赖层和源码层要分开Docker 构建缓存的关键在于一条指令的输入变了缓存才失效。依赖层只需要package-lock.json变化。源码层只要COPY . .涉及的文件变化。两者混在一起缓存就形同虚设。所以 Dockerfile 的编写顺序要求首先只拷贝依赖清单COPY package.json package-lock.json ./运行依赖安装RUN npm ci最后拷贝全部源码COPY . .这样开发者每次修改代码重建时的前两步都能命中缓存只有COPY . .之后的层重新构建。实测在依赖较多时构建时间从原来的 5 分钟压到 30 秒以内。但这里有个坑COPY . .会把本地目录里的一切都送进构建上下文包括node_modules、dist、.git、日志文件、临时文件。这不仅让构建变慢还可能把敏感信息带进镜像。解决办法是.dockerignore文件在构建时直接排除无用文件node_modules dist .git *.log .DS_Store .gitignore Dockerfile docker-compose.yml README.md4.2 从“少带垃圾”到“垃圾全程不进屋”相比传统镜像多阶段构建还有一个隐性收益构建阶段里你装了什么工具、向容器里复制了什么内容这些都不影响最终镜像。所以构建阶段可以大胆使用方便的工具甚至可以直接把密码、密钥文件塞进去只要保证不复制到最终阶段就行。需要注意的是构建阶段的内容虽然不进入最终镜像但会留在构建缓存里。如果敏感信息需要彻底清除可以用docker builder prune清理构建缓存$ docker builder prune -af4.3 非 root 用户运行安全不可忽略镜像瘦身后安全方面更重要。很多镜像默认以 root 运行一旦应用被攻破攻击者直接就拿到了容器内的最高权限。绝大多数发行版的应用镜像都有现成的非 root 用户比如node镜像自带node用户。生产运行阶段调整FROM node:20-alpine AS production WORKDIR /app ENV NODE_ENVproduction COPY --frombuild /app/package*.json ./ COPY --frombuild /app/node_modules ./node_modules COPY --frombuild /app/dist ./dist USER node EXPOSE 3000 CMD [node, dist/index.js]注意USER node这一行。加这一条容器进程就以普通用户身份运行了。如果应用需要写文件要注意工作目录的权限如果用绑定挂载卷宿主机的目录权限也要给到位。4.4 基础镜像的版本选择alpine 不是万能的多阶段构建讲解中基础镜像的选择很容易被忽略。node:20-alpine是常见选择但 alpine 基于 musl libc与 glibc 存在差异。依赖了某些原生模块的项目可能会遇到在 alpine 里编译不过去的问题。遇到这种情况有两个处理方向保留 Debian 系基础镜像但选择slim版本。比如node:20-slim比完整版小不少保留了 apt 包管理器兼容性比 alpine 好。构建阶段用完整环境运行阶段用slim版本依赖在构建阶段编译好把产物复制过去。对于依赖复杂、有原生模块的项目我的经验是构建阶段用完整镜像保证编译通过运行阶段用slim镜像保证兼容性比强行上 alpine 省事得多。4.5 构建 Dockerfile 的检查表结合上面的经验整理一个生产级多阶段 Dockerfile 的常见参考# ---- 依赖安装阶段 ---- FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci # ---- 构建阶段 ---- FROM node:20-alpine AS build WORKDIR /app COPY --fromdeps /app/node_modules ./node_modules COPY . . RUN npm run build # ---- 生产运行阶段 ---- FROM node:20-alpine AS production WORKDIR /app ENV NODE_ENVproduction COPY package*.json ./ RUN npm ci --omitdev npm cache clean --force COPY --frombuild /app/dist ./dist USER node EXPOSE 3000 CMD [node, dist/index.js]注意这里deps阶段用了npm ci生产开发依赖全装而生产阶段用了npm ci --omitdev只装生产依赖。为什么deps阶段的产物只服务构建阶段构建可能需要各种开发依赖生产阶段运行只需要生产依赖。这样最终镜像里的node_modules是最精简的。同时我加了一行npm cache clean --force清理 npm 缓存防止缓存文件进入镜像层。5. 多阶段构建的局限与常见误区听了这么多好处别急着把项目全部改掉有几个误区和局限性必须提前说清楚。5.1 误区一个 Dockerfile 只有一份 COPY --from会写多阶段构建的人不少但没几个人想过是不是只能从上一个阶段复制文件实际上COPY --from可以引用任意一个已经构建完成的阶段名称。举个例子FROM node:20-alpine AS deps ... FROM golang:1.22-alpine AS builder ... FROM node:20-alpine AS production COPY --fromdeps /app/node_modules ./node_modules COPY --frombuilder /app/server ./server不同语言工具的构建产物可以汇聚到同一个生产镜像里。这种“多阶段组合”的思路适合边车模式的架构一个容器里跑 Node 应用同时挂一个用 Go 写的小工具。5.2 误区所有项目都该追求最小体积镜像体积小是目标但也要顾全实际。我用 scratch 镜像部署过静态编译的 Go 服务确实小但排查问题时痛苦不堪没有ps看进程、没有ls看目录、没有sh进去手动执行命令。另外运行时要加载系统证书的 Java 应用、需要调用系统库的 Python 应用都不能无脑搬 scratch。安全性比体积更重要可维护性也比体积更重要。镜像从 1GB 减到 100MB 是合理的性价比区间再继续死磕几十 MB运维成本会急剧上升。我的经验是越接近运行时越要保守越靠构建阶段越可以激进。5.3 局限多阶段构建解决不了基础层的漏洞多阶段构建能带走构建阶段安装的各种工具但基础镜像本身的系统库和运行时漏洞仍然会保留在最终镜像里。比如运行阶段用的是node:20-alpine如果 alpine 的某个系统包存在 CVE最终镜像还是暴露在风险之下。所以安全维护一定不能只靠镜像瘦身。基础镜像要定期升级镜像构建要接入扫描工具删除高危依赖这才是长期安全策略。6. 多阶段构建在 CI/CD 中的落地方式前面讲的都是 Dockerfile 本身但生产环境真正跑起来构建流程往往在 CI/CD 管道里执行有几个细节不处理好镜像瘦身就白搭了。6.1 流水线中 target 参数的应用一个项目往往同时需要开发镜像和生产镜像。开发环境想要带调试工具、热重载的完整环境生产环境想要精简的产物。不用维护两份 Dockerfile多阶段构建配合--target参数就能解决# 开发环境构建 development 阶段 docker build --target development -t myapp:dev . # 生产环境构建 production 阶段 docker build --target production -t myapp:prod .Dockerfile 只需要增加一个 development 阶段FROM node:20-alpine AS development WORKDIR /app COPY package*.json ./ RUN npm install COPY . . CMD [npm, run, dev]然后按上面示例development 阶段在中间produciton 阶段在最后。docker build --target可以直接指定构建到某一个阶段为止。6.2 BuildKit 和构建缓存挂载Docker 18.09 之后推荐用 BuildKit 做镜像构建。它带来的一个关键能力是RUN --mounttypecache可以把包管理器缓存挂载到宿主机上不用写进镜像层又不会每次构建都重新下载。在 Dockerfile 中如果有依赖安装需求可以这么写FROM node:20-alpine AS deps RUN apk add --no-cache python3 make g WORKDIR /app COPY package*.json ./ RUN --mounttypecache,target/root/.npm npm ci这行RUN --mounttypecache会把 npm 缓存挂载到临时位置容器结束时不写入镜像层但宿主机上保留了缓存下次构建如果依赖没变直接就用了速度会有非常明显的提升。BuildKit 默认在开启状态如果你的 Docker 版本比较老构建时可以通过环境变量开启DOCKER_BUILDKIT1 docker build -t myapp:prod .6.3 与镜像仓库的配合镜像瘦身了如果叠加一层漏洞扫描效果更好。构建后的推送流程建议配合 Trivy 这类开源扫描工具# 扫描镜像漏洞 trivy image myapp:prod扫描报告里如果出现基础镜像的高危漏洞往往需要升级基础镜像版本而非继续压缩体积。镜像小了扫描速度也会快很多这算是瘦身带来的一个附加好处。写在最后多阶段构建不是什么黑魔法它只是把镜像制作流程中“构建”和“运行”两个阶段彻底分开了。理解这一点后你会发现不仅是体积连构建速度、缓存利用率、安全面收缩都一起解决了。个人体会是项目改造有快慢不需要一次性把所有镜像都改成多阶段。选一个体积开销最大、依赖最重的服务作为试点改完对比一下体积和构建时间数据说话团队自然就愿意推下去了。我自己每次做容器化方案评审第一件事就是打开团队 Dockerfile 看阶段的划分——这不是教条是真真切切靠实践验证出来的经验。