双十一备战的那些天,我接手了一个运行了三年的老牌电商订单系统的重构任务。这个系统基于 Java 8 和 Spring Boot 2.3.1 构建,当时的部署流程非常传统:开发人员在本地打包成 order-service.jar,然后丢进一个基于 openjdk:8u265-jdk 的镜像里。我拉取线上最新的镜像时,发现体积竟然达到了 1.2GB。
这带来的问题不仅是存储空间的浪费。我们的 Kubernetes 集群在大促期间需要快速扩容,1.2GB 的镜像拉取时间平均在 90 秒以上,导致扩容响应滞后。更严重的是,这个镜像里包含了 JDK 编译环境、Maven 构建残留以及大量的 Shell 工具,在一次安全扫描中,被检测出 17 个高危漏洞,其中大部分来自于镜像内未清理的 apt 依赖包。
原因在于,传统的单体构建方式将“编译环境”和“运行环境”混在了一起。JDK 本身就接近 600MB,再加上我们引入的各种监控 Agent 和临时文件,体积失控是必然的。
解决方案是引入 Docker Engine 自 v17.05.0-ce 引入的多阶段构建特性(目前 Docker Engine v26.x 已将其作为标准特性维护)。我将构建过程拆分为两个阶段:第一阶段负责编译,第二阶段负责运行。
以下是我重构后的 Dockerfile 实战代码:
在这个方案中,我做了几个关键决策。第一,第一阶段使用了包含 Maven 的完整 JDK 镜像,确保编译能力;第二,第二阶段我切换到了 eclipse-temurin:8-jre-alpine,这是一个仅包含 JRE 的轻量级镜像,剔除了编译器和不必要的工具。第三,利用 COPY --from=builder 指令,精准地只提取了 85MB 左右的 Jar 包。
最终效果是立竿见影的。新镜像的体积从 1.2GB 骤降至 85MB,缩减幅度达到了 93%。在 100Mbps 的带宽环境下,镜像拉取时间从 90 秒缩短到了 6 秒。由于移除了 Shell 和多余的库文件,安全扫描的漏洞数量直接归零。这就是多阶段构建最直观的价值:通过逻辑隔离,剔除一切与运行无关的产物。
在推广多阶段构建的过程中,我遇到了另一个棘手的问题。团队里的前端项目使用 Node.js 16 构建 Vue 3 应用,虽然通过多阶段构建将最终镜像从 1GB 压缩到了 200MB,但构建过程本身却变得异常缓慢。每次在 Jenkins 上触发构建,即使只修改了一行业务逻辑代码,npm install 都会重新下载 800 多兆的依赖包,导致构建耗时从原本的 2 分钟拉长到了 8 分钟。
原因在于,Docker 的层缓存机制虽然强大,但在处理 RUN npm install 时存在局限。只要 package-lock.json 文件发生变动,或者上下文导致缓存失效,Docker 就会重新执行该指令,且不会复用宿主机或上一轮构建的 node_modules 缓存。在 CI/CD 环境中,每次构建都是全新的容器环境,这就导致了大量的重复下载。
解决方案是启用 Docker 的 BuildKit 后端(目前 Docker Engine v26.x 已将其作为默认引擎)。BuildKit 提供了 RUN --mount=type=cache 指令,允许我们在构建过程中挂载一个持久化的缓存目录。
我修改了前端的 Dockerfile,具体代码如下:
在这个配置中,--mount=type=cache,target=/root/.npm 是关键。它告诉 BuildKit,在运行 npm ci 时,将 /root/.npm 目录挂载为一个缓存卷。这意味着第一次下载的依赖会被保留在构建器的缓存存储中,后续构建会直接命中这个缓存,而不是重新从网络下载。
实际测试数据显示,在引入该特性后,我们的前端项目构建耗时从 480秒 降低到了 45秒,效率提升了 90% 以上。此外,BuildKit 还支持 --mount=type=secret,我在处理需要拉取私有 Git 仓库依赖的后端 Go 项目时,用它来安全传递 SSH 密钥,避免了将密钥打包进镜像的历史层中的风险。这种对构建过程的精细化控制,是多阶段构建在 2024 年及未来发展的核心趋势之一。
去年我们接入了一家国有银行的合作项目,对方的运维规范极其严格,要求生产镜像必须满足“最小攻击面”原则,具体指标是:镜像内不得包含 Shell 解释器(如 sh、bash)、包管理器(如 apt、apk)以及任何非必要的系统库。
当时我们的服务运行在标准的 alpine 或 debian-slim 镜像上,虽然体积不大,但依然包含 sh 和 apk。安全审计团队给出的理由是:一旦攻击者通过 Web 漏洞获得了代码执行权限,他们就能利用镜像内的 Shell 进行横向移动或下载恶意工具。如果不解决这个问题,系统无法通过合规验收。
原因在于,传统的 Linux 发行版镜像设计初衷是通用性,包含了运维调试所需的工具,但这与高安全场景下的“最小权限”原则相悖。
解决方案是采用 Google 的 Distroless 镜像配合多阶段构建。Distroless 镜像只包含应用程序及其运行时依赖,没有 Shell 和包管理器。我调整了订单服务的 Dockerfile:
在这个方案中,我选择了 gcr.io/distroless/static-debian11。这是一个基于 Debian 11 但剥离了几乎所有内容的镜像。我特别在编译 Go 代码时加上了 CGO_ENABLED=0,确保生成的是静态链接的二进制文件,不依赖任何外部动态库。
实施后的效果非常显著。新的镜像体积仅为 12MB,比之前的 JRE 版本还要小。我尝试通过漏洞注入的方式进入容器,执行 docker exec -it ,结果直接报错:executable file not found in $PATH。这意味着攻击者即便突破了应用层,也没有任何工具可以在容器内执行命令,极大地提高了系统的防御纵深。
这种结合 Distroless 的实践,正是当前软件供应链安全(SBOM 与签名集成)趋势下的重要一环。配合 Docker Scout 进行扫描,我们能确保最终交付的镜像既轻量又极难被渗透,完美满足了金融级的安全合规要求。
去年我们团队把核心支付服务从 x86 的云主机迁移到自建的 ARM64 集群,本来以为改个编译参数就完事了,结果在 Docker 镜像构建这儿卡了整整两天。那时候我才意识到,多阶段构建在多架构场景下,缓存和构建策略的坑比想象中深得多。
我们当时的支付服务用的是 Go 1.21,Docker Engine 版本是 v24.0.7(现在生产已经升到 v26.1.3 了)。一开始我直接在 Jenkins 的构建脚本里加了 docker buildx build --platform linux/amd64,linux/arm64 -t pay-svc:1.0.0 .,结果每次构建都要重新拉取 golang:1.21-alpine 的基础镜像,而且编译阶段完全没走缓存,一个镜像构建耗时从原来的 3 分钟飙升到 12 分钟,CI 流水线直接堵成了一锅粥。
后来我翻 BuildKit 的文档才发现,多阶段构建的缓存是和平台绑定的。你给 x86 构建的 Go 依赖缓存,ARM64 根本用不上。而且我之前没给构建阶段命名,导致 docker buildx 在复用缓存时找不到对应的阶段。我改了 Dockerfile,给编译阶段显式命名为 builder,并且用 RUN --mount=type=cache 挂载 Go 的模块缓存目录,这样不同平台的构建都能共享依赖缓存,不用每次都重新下载。
下面是当时优化后的 Dockerfile 片段,我们线上跑了快一年,构建耗时稳定在 2 分半左右:
这里有个细节,$BUILDPLATFORM 和 $TARGETPLATFORM 是 docker buildx 内置的自动参数,不用我们手动传。我之前傻呵呵地在 Jenkins 里手动传 TARGETARCH,结果 ARM64 构建时传成了 arm64v8,Go 编译直接报错,排查了半小时才发现是架构名不匹配。
还有缓存复用的问题,我们之前 CI 里每次构建都会先 docker system prune -f,美其名曰清理空间,结果把 BuildKit 的缓存层全删了,导致每次构建都从零开始。后来我把 Jenkins 的清理策略改成了只删 7 天前的未使用镜像,并且给 docker buildx build 加了 --cache-from type=local,src=/var/cache/docker/buildx 和 --cache-to type=local,dest=/var/cache/docker/buildx,mode=max,把缓存持久化到宿主机目录,现在即使 Jenkins 重启,缓存也不会丢。
现在我们支付服务的镜像体积从原来的 320MB(单阶段构建,带 golang 环境)降到了 12MB,ARM64 和 x86 的镜像标签统一,K8s 调度时自动匹配节点架构,大促时扩容速度比以前快了 40%,因为小镜像拉取只需要 1.2 秒,之前 320MB 的镜像要拉 18 秒。
上个月我们那个订单详情接口突然报警,P99 响应时间从 120ms 涨到了 800ms,紧接着几个节点就 OOM 重启了。我登上去看监控,发现 RSS 内存占用从平时的 180MB 飙到了 1.2GB,第一反应是代码里有内存泄漏,翻了半小时 pprof 没找到问题,最后才反应过来是上周改 Dockerfile 时埋的坑。
事情是这样的,我们订单服务用的是 Java 17,之前的多阶段构建是把 Maven 编译后的 jar 包复制到 OpenJDK 运行镜像里。上周我想优化镜像体积,就把运行阶段的基础镜像从 openjdk:17-jre-slim 换成了 eclipse-temurin:17-jre-alpine,并且在 Dockerfile 里加了一行 RUN rm -rf /tmp/*,想清理临时文件。结果就是这行 rm,触发了多阶段构建的层合并陷阱。
我之前一直以为多阶段构建里,每个 RUN 指令都是独立的层,只要不在同一个阶段,就不会互相影响。但实际上,如果你在最终阶段用了 COPY --from=builder 复制文件,并且后续有 RUN 指令修改了这些文件所在的目录,Docker 会把之前的复制层和后续的修改层合并吗?不对,不是合并,是我犯了个低级错误:我把 rm -rf /tmp/* 写在了 COPY --from=builder 的前面,而我们的 Java 应用启动时会往 /tmp 写缓存文件,Alpine 镜像的 /tmp 目录权限默认是 rwxr-xr-x,但我在 rm 之后没有重新创建 /tmp 的正确权限,导致应用写 /tmp 时失败,然后不断重试,内存里的重试队列越积越多,最后 OOM。
更坑的是,我当时为了减小镜像体积,把多个 RUN 指令合并成了一行,比如 RUN apk add --no-cache curl && rm -rf /var/cache/apk/* && rm -rf /tmp/*,这行指令在构建时确实只产生一个层,看起来很精简。但问题就在于,这个 rm -rf /tmp/* 把基础镜像里 /tmp 目录下的隐藏文件(比如 .keep)给删了,而 Alpine 的 /tmp 目录如果没有这些文件,权限会被重置成 755,而我们的 Java 进程是用非 root 用户运行的,没有写权限。
下面是当时出问题的 Dockerfile 片段,我打了马赛克也不行,直接贴出来给大家避坑:
排查过程是这样的:我先用 docker run -it --rm --entrypoint sh order-service:latest 进入容器,发现 /tmp 目录的 owner 是 root,权限是 755,而 appuser 没有写权限。然后我看了 Eclipse Temurin Alpine 镜像的官方 Dockerfile,发现他们默认会创建 /tmp 目录并赋予 1777 权限(粘滞位),我那一波 rm -rf /tmp/* 直接把粘滞位给搞没了。
修复方法也很简单,要么把 rm -rf /tmp/* 删掉,要么在 rm 之后重新创建 /tmp 并设置权限。我后来改成了这样,把清理操作放在单独的层,并且不碰 /tmp:
还有一个层合并的陷阱:如果你在多个阶段用了同一个基础镜像,比如 builder 阶段用 golang:1.21-alpine,运行阶段也用 alpine:3.18,那么这两个阶段的 Alpine 基础层是共享的,不会占用双倍空间。但如果运行阶段你用了 alpine:3.19,那么这两个基础层就会同时存在,镜像体积会变大。我之前给前端服务做多阶段构建,builder 用 node:20-alpine3.17,运行阶段用 nginx:1.25-alpine3.18,结果镜像比预期大了 40MB,后来统一成 alpine3.18 才降下来。
现在我们团队的规范是:多阶段构建里,除了复制产物和必要的配置,不要在最终阶段做太多 RUN 操作;清理临时文件尽量在 builder 阶段做,比如 RUN go build ... && rm -rf /app/src;永远不要碰 /tmp、/var/log 这些系统默认目录,除非你明确知道需要修改什么。
去年我们公司过了一次等保三级测评,其中有一条要求是所有上线镜像必须提供 SBOM(软件物料清单),并且要扫描已知漏洞。那时候我才知道,Docker 多阶段构建产出的镜像,虽然体积小了,但如果没有 SBOM,安全团队根本不让你上线。
我一开始以为 SBOM 是那种要手动整理的文件,后来发现 Docker Engine v26.0.0 之后,Docker Scout 已经内置了 SBOM 生成功能,而且能直接和多阶段构建联动。我们之前有个 Go 服务的镜像,用多阶段构建后体积只有 15MB,但安全团队用 Trivy 扫出来有 3 个高危漏洞,我一开始以为是 Go 运行时的问题,结果用 Docker Scout 一看,漏洞来自 builder 阶段用的 golang:1.20-alpine 里的 apk 包,而我的运行阶段用的是 distroless,根本不包含这些包,但因为没有 SBOM,安全工具不知道这些漏洞包已经被丢弃了。
后来我在 CI 流水线里加了 Docker Scout 的扫描步骤,并且生成 SBOM 文件一起归档。下面是我们在 GitHub Actions 里的构建配置片段,现在每次 PR 合并都会自动跑:
这里有个关键参数 only-final-stage: true,这个参数太重要了。我之前没加这个,Docker Scout 会把 builder 阶段里的 golang 镜像漏洞也算进去,导致每次扫描都报 10 多个中危漏洞,其实这些漏洞根本不会出现在最终运行的镜像里。加上这个参数后,扫描结果只关注最终阶段的组件,我们的镜像漏洞数直接从 12 个降到了 0 个。
还有一次,我们的前端服务用了 lodash 的一个旧版本,有原型污染漏洞,Docker Scout 直接在扫描阶段就报错了,阻止了镜像推送。我一开始还纳闷,我前端多阶段构建里,builder 阶段用的是 node:20,运行阶段用的是 nginx:alpine,lodash 只在 builder 阶段存在啊。结果查了才发现,我在 COPY --from=builder 的时候,不小心把 node_modules 整个复制到了 nginx 镜像里,导致漏洞包被带到了最终阶段。后来我改了复制指令,只复制 dist 目录,问题就解决了:
现在我们团队的流程是:多阶段构建完成后,自动生成 SBOM(格式是 SPDX,行业通用标准),然后用 Docker Scout 扫描最终阶段的漏洞,只有 0 高危漏洞才允许推送到生产镜像仓库。而且 SBOM 会和应用版本绑定,存到 GitLab 的制品库里,等保测评的时候直接导出就行,不用临时抱佛脚整理。
根据 Docker 官方 2024 年的趋势报告,现在越来越多的企业要求镜像必须带 SBOM 和签名,多阶段构建本身就是供应链安全的第一道防线——因为它天然丢弃了构建工具和源码,减少了攻击面。但如果没有 SBOM 和扫描,你还是不知道最终镜像里到底包含了哪些组件,有没有已知漏洞。我之前见过一个团队,用多阶段构建把 Java 镜像从 500MB 降到了 50MB,但里面包含的 log4j 2.14.0 漏洞没被发现,上线后被攻击了,这就是只优化体积没做安全扫描的教训。
现在我们生产环境的所有镜像,都是用 Docker Engine v26.1.3 构建的,默认开启 BuildKit,多阶段构建 + Docker Scout 扫描 + SBOM 生成已经是标准流程,每次构建耗时只增加了 15 秒左右,但安全合规性直接达标,这 15 秒花得绝对值。
去年双十一前,我负责一个大促活动页生成服务。当时为了赶进度,我直接把 Maven 构建环境塞进了运行镜像里,导致镜像体积飙到了 1.2GB。
上线前压测,问题来了:K8s 集群节点拉取镜像慢得像蜗牛,Pod 启动经常超时。更离谱的是,因为基础镜像里自带了一堆调试工具,被安全组扫描出好几个高危漏洞,直接卡住了发布流程。
我当时急了,连夜改成了多阶段构建。但问题来了,我为了极致瘦身,在最终阶段用了 alpine,结果服务跑起来直接报 GLIBC 版本不兼容,线上直接 500。
排查过程很痛苦,我盯着日志看了半天才反应过来,Java 应用对底层 C 库是有依赖的。最后我妥协了,最终镜像换成了 eclipse-temurin 的 distroless 版本。虽然体积比 alpine 大了一点点(大概多了 30MB),但胜在稳定,而且彻底去掉了 Shell,安全组也闭嘴了。
* 别盲目追求极致小:如果你的应用是 Node.js 或 Python,且依赖了原生模块,别死磕 Alpine。有时候为了那几 MB 去折腾兼容性,投入产出比太低。
* 什么时候该用:微服务、Serverless 函数、或者需要大规模扩缩容的场景,多阶段构建是标配。镜像小,意味着调度快、冷启动快。
* 什么时候算了:如果你只是本地跑个 Demo,或者是个单体巨石应用,没必要折腾。直接 docker build 一把梭,省下的时间喝杯咖啡不香吗?
学习多阶段构建,别光看文档里的 COPY --from=builder。一定要去理解镜像的分层原理。很多时候构建慢,不是因为你没用多阶段,而是因为你把 RUN npm install 放在了代码复制之后,导致缓存永远失效。先装依赖,再拷代码,这个小习惯能让你构建快十倍。