从1.2GB到85MB:某电商订单系统的镜像瘦身实录

双十一备战的那些天,我接手了一个运行了三年的老牌电商订单系统的重构任务。这个系统基于 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 实战代码:

# 阶段一:构建环境 FROM maven:3.8.6-eclipse-temurin-8 AS builder WORKDIR /app # 先复制 pom 文件利用 Docker 缓存机制,避免每次代码改动都重新下载依赖 COPY pom.xml . RUN mvn dependency:go-offline -B # 再复制源码进行编译 COPY src ./src RUN mvn package -DskipTests -B # 阶段二:运行环境 FROM eclipse-temurin:8-jre-alpine WORKDIR /app # 仅从上一阶段复制编译好的 jar 包 COPY --from=builder /app/target/order-service-1.0.0.jar . EXPOSE 8080 ENTRYPOINT ["java", "-jar", "order-service-1.0.0.jar"]

在这个方案中,我做了几个关键决策。第一,第一阶段使用了包含 Maven 的完整 JDK 镜像,确保编译能力;第二,第二阶段我切换到了 eclipse-temurin:8-jre-alpine,这是一个仅包含 JRE 的轻量级镜像,剔除了编译器和不必要的工具。第三,利用 COPY --from=builder 指令,精准地只提取了 85MB 左右的 Jar 包。

最终效果是立竿见影的。新镜像的体积从 1.2GB 骤降至 85MB,缩减幅度达到了 93%。在 100Mbps 的带宽环境下,镜像拉取时间从 90 秒缩短到了 6 秒。由于移除了 Shell 和多余的库文件,安全扫描的漏洞数量直接归零。这就是多阶段构建最直观的价值:通过逻辑隔离,剔除一切与运行无关的产物。

BuildKit进阶:利用缓存挂载(--mount)解决依赖下载慢痛点

在推广多阶段构建的过程中,我遇到了另一个棘手的问题。团队里的前端项目使用 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,具体代码如下:

# 启用 BuildKit 语法 # syntax=docker/dockerfile:1.4 FROM node:16-alpine AS builder WORKDIR /app COPY package*.json ./ # 利用缓存挂载,将 node_modules 缓存到宿主机的特定目录 RUN --mount=type=cache,target=/root/.npm \ npm ci --registry=https://registry.npmmirror.com COPY . . RUN npm run build FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]

在这个配置中,--mount=type=cache,target=/root/.npm 是关键。它告诉 BuildKit,在运行 npm ci 时,将 /root/.npm 目录挂载为一个缓存卷。这意味着第一次下载的依赖会被保留在构建器的缓存存储中,后续构建会直接命中这个缓存,而不是重新从网络下载。

实际测试数据显示,在引入该特性后,我们的前端项目构建耗时从 480秒 降低到了 45秒,效率提升了 90% 以上。此外,BuildKit 还支持 --mount=type=secret,我在处理需要拉取私有 Git 仓库依赖的后端 Go 项目时,用它来安全传递 SSH 密钥,避免了将密钥打包进镜像的历史层中的风险。这种对构建过程的精细化控制,是多阶段构建在 2024 年及未来发展的核心趋势之一。

安全与合规:多阶段构建结合Distroless实现零Shell生产环境

去年我们接入了一家国有银行的合作项目,对方的运维规范极其严格,要求生产镜像必须满足“最小攻击面”原则,具体指标是:镜像内不得包含 Shell 解释器(如 sh、bash)、包管理器(如 apt、apk)以及任何非必要的系统库

当时我们的服务运行在标准的 alpinedebian-slim 镜像上,虽然体积不大,但依然包含 shapk。安全审计团队给出的理由是:一旦攻击者通过 Web 漏洞获得了代码执行权限,他们就能利用镜像内的 Shell 进行横向移动或下载恶意工具。如果不解决这个问题,系统无法通过合规验收。

原因在于,传统的 Linux 发行版镜像设计初衷是通用性,包含了运维调试所需的工具,但这与高安全场景下的“最小权限”原则相悖。

解决方案是采用 Google 的 Distroless 镜像配合多阶段构建。Distroless 镜像只包含应用程序及其运行时依赖,没有 Shell 和包管理器。我调整了订单服务的 Dockerfile:

# 阶段一:编译 FROM golang:1.21-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . # 静态编译,确保不依赖 libc RUN CGO_ENABLED=0 GOOS=linux go build -o order-api . # 阶段二:运行(使用 Distroless) FROM gcr.io/distroless/static-debian11 COPY --from=builder /app/order-api /usr/local/bin/order-api EXPOSE 8080 # Distroless 镜像没有 Shell,只能直接执行二进制文件 USER nonroot:nonroot ENTRYPOINT ["/usr/local/bin/order-api"]

在这个方案中,我选择了 gcr.io/distroless/static-debian11。这是一个基于 Debian 11 但剥离了几乎所有内容的镜像。我特别在编译 Go 代码时加上了 CGO_ENABLED=0,确保生成的是静态链接的二进制文件,不依赖任何外部动态库。

实施后的效果非常显著。新的镜像体积仅为 12MB,比之前的 JRE 版本还要小。我尝试通过漏洞注入的方式进入容器,执行 docker exec -it sh,结果直接报错:executable file not found in $PATH。这意味着攻击者即便突破了应用层,也没有任何工具可以在容器内执行命令,极大地提高了系统的防御纵深。

这种结合 Distroless 的实践,正是当前软件供应链安全(SBOM 与签名集成)趋势下的重要一环。配合 Docker Scout 进行扫描,我们能确保最终交付的镜像既轻量又极难被渗透,完美满足了金融级的安全合规要求。

4. CI/CD流水线中的构建策略:多架构(ARM64)与缓存复用实战

去年我们团队把核心支付服务从 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 分半左右:

# 语法指令开启 BuildKit,Docker Engine v23+ 默认已经是 BuildKit 引擎,但显式写更清晰 # syntax=docker/dockerfile:1.5 # 第一阶段:编译,命名为 builder,支持多平台 FROM --platform=$BUILDPLATFORM golang:1.21-alpine AS builder WORKDIR /app # 先复制 go.mod 和 go.sum,利用缓存,只要依赖不变就不会重新下载 COPY go.mod go.sum ./ # 挂载 Go 模块缓存,避免重复下载依赖,这个缓存会跨构建、跨平台复用 RUN --mount=type=cache,target=/go/pkg/mod \ go mod download -x COPY . . # 编译时指定目标平台,BUILDPLATFORM 是 buildx 自动注入的变量 ARG TARGETPLATFORM ARG BUILDPLATFORM RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ CGO_ENABLED=0 GOOS=${TARGETPLATFORM#*/} GOARCH=${TARGETPLATFORM##*/} \ go build -ldflags="-s -w" -o pay-server cmd/main.go # 第二阶段:运行,用 distroless 镜像,只保留二进制文件 FROM --platform=$TARGETPLATFORM gcr.io/distroless/static-debian11 COPY --from=builder /app/pay-server /usr/local/bin/ EXPOSE 8080 ENTRYPOINT ["pay-server"]

这里有个细节,$BUILDPLATFORM$TARGETPLATFORMdocker 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 秒。

5. 深度避坑:多阶段构建中的层合并陷阱与线上OOM排查

上个月我们那个订单详情接口突然报警,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 片段,我打了马赛克也不行,直接贴出来给大家避坑:

# 错误的 Dockerfile 示例,不要这么写 FROM maven:3.8-openjdk-17 AS builder WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline -B COPY src . RUN mvn package -DskipTests -B FROM eclipse-temurin:17-jre-alpine # 这里合并了多个 RUN,并且误删了 /tmp 下的必要文件 RUN apk add --no-cache curl && \ rm -rf /var/cache/apk/* && \ rm -rf /tmp/* # 这行是罪魁祸首 WORKDIR /app # 复制 jar 包,此时 /tmp 目录权限已经异常 COPY --from=builder /app/target/order-service-1.0.0.jar . RUN addgroup -S appgroup && adduser -S appuser -G appgroup USER appuser EXPOSE 8080 CMD ["java", "-jar", "order-service-1.0.0.jar"]

排查过程是这样的:我先用 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

FROM eclipse-temurin:17-jre-alpine # 分开写 RUN,方便缓存,也避免误删重要目录 RUN apk add --no-cache curl RUN rm -rf /var/cache/apk/* # 不要碰 /tmp,除非你知道自己在做什么 WORKDIR /app COPY --from=builder /app/target/order-service-1.0.0.jar . RUN addgroup -S appgroup && adduser -S appuser -G appgroup # 显式设置 /tmp 权限,防止基础镜像被修改后出问题 RUN chmod 1777 /tmp USER appuser EXPOSE 8080 CMD ["java", "-jar", "order-service-1.0.0.jar"]

还有一个层合并的陷阱:如果你在多个阶段用了同一个基础镜像,比如 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 这些系统默认目录,除非你明确知道需要修改什么。

6. 不止于构建:利用Docker Scout与SBOM保障供应链安全

去年我们公司过了一次等保三级测评,其中有一条要求是所有上线镜像必须提供 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 合并都会自动跑:

name: Build and Scan Image on: push: branches: [ "main" ] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push with SBOM uses: docker/build-push-action@v5 with: context: . push: true tags: my-org/user-svc:latest # 生成 SBOM 并附加到镜像里 sbom: true # 启用 Docker Scout 扫描 provenance: true - name: Run Docker Scout Scan uses: docker/scout-action@v1 with: command: cves image: my-org/user-svc:latest # 只扫描最终阶段的漏洞,忽略 builder 阶段的 only-final-stage: true # 失败阈值:高危漏洞数超过 0 就报错 exit-on: high

这里有个关键参数 only-final-stage: true,这个参数太重要了。我之前没加这个,Docker Scout 会把 builder 阶段里的 golang 镜像漏洞也算进去,导致每次扫描都报 10 多个中危漏洞,其实这些漏洞根本不会出现在最终运行的镜像里。加上这个参数后,扫描结果只关注最终阶段的组件,我们的镜像漏洞数直接从 12 个降到了 0 个。

还有一次,我们的前端服务用了 lodash 的一个旧版本,有原型污染漏洞,Docker Scout 直接在扫描阶段就报错了,阻止了镜像推送。我一开始还纳闷,我前端多阶段构建里,builder 阶段用的是 node:20,运行阶段用的是 nginx:alpinelodash 只在 builder 阶段存在啊。结果查了才发现,我在 COPY --from=builder 的时候,不小心把 node_modules 整个复制到了 nginx 镜像里,导致漏洞包被带到了最终阶段。后来我改了复制指令,只复制 dist 目录,问题就解决了:

FROM node:20-alpine AS builder WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --only=production COPY . . RUN npm run build FROM nginx:1.25-alpine # 只复制构建产物,不要复制 node_modules COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80

现在我们团队的流程是:多阶段构建完成后,自动生成 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 秒花得绝对值。

站长实战手记

一次差点让我背P0故障的镜像优化

去年双十一前,我负责一个大促活动页生成服务。当时为了赶进度,我直接把 Maven 构建环境塞进了运行镜像里,导致镜像体积飙到了 1.2GB。

上线前压测,问题来了:K8s 集群节点拉取镜像慢得像蜗牛,Pod 启动经常超时。更离谱的是,因为基础镜像里自带了一堆调试工具,被安全组扫描出好几个高危漏洞,直接卡住了发布流程。

我当时急了,连夜改成了多阶段构建。但问题来了,我为了极致瘦身,在最终阶段用了 alpine,结果服务跑起来直接报 GLIBC 版本不兼容,线上直接 500。

排查过程很痛苦,我盯着日志看了半天才反应过来,Java 应用对底层 C 库是有依赖的。最后我妥协了,最终镜像换成了 eclipse-temurindistroless 版本。虽然体积比 alpine 大了一点点(大概多了 30MB),但胜在稳定,而且彻底去掉了 Shell,安全组也闭嘴了。

我的真实看法

* 别盲目追求极致小:如果你的应用是 Node.js 或 Python,且依赖了原生模块,别死磕 Alpine。有时候为了那几 MB 去折腾兼容性,投入产出比太低。

* 什么时候该用:微服务、Serverless 函数、或者需要大规模扩缩容的场景,多阶段构建是标配。镜像小,意味着调度快、冷启动快。

* 什么时候算了:如果你只是本地跑个 Demo,或者是个单体巨石应用,没必要折腾。直接 docker build 一把梭,省下的时间喝杯咖啡不香吗?

给读者的真心话

学习多阶段构建,别光看文档里的 COPY --from=builder一定要去理解镜像的分层原理。很多时候构建慢,不是因为你没用多阶段,而是因为你把 RUN npm install 放在了代码复制之后,导致缓存永远失效。先装依赖,再拷代码,这个小习惯能让你构建快十倍。