Ohhnews

分类导航

$ cd ..
DZone Java原文

Docker构建中隐藏的启动时间技巧:用CDS为Spring Boot应用提速

#docker#spring boot#jvm#cds#启动优化

每个在 Kubernetes 上运行服务的 Java 开发者都见过这样的场景:流量飙升,自动扩缩容增加了一个 Pod,然后所有人开始等待。容器两秒内就运行起来了,但应用还要再等十二秒才能就绪。在这十秒里,现有 Pod 在消化额外负载,延迟攀升;如果情况足够糟糕,自动扩缩容会因恐慌而增加更多 Pod,而这些 Pod 同样尚未就绪。

多年来,我一直把 Spring Boot 的启动时间当作不可改变的现实,就像你接受天气一样。直到我发现 JVM 从 Java 12 开始就提供了一个能解决大部分问题的方案;它在 Docker 内部运行得很好,却几乎没人把它打包进镜像。这个方案叫 Class Data Sharing,简称 CDS。本文会告诉你如何让 Docker 构建替你完成这项工作。

那十二秒到底用在了哪里

Spring Boot 应用启动时,JVM 大部分时间并不是在运行你的代码,而是在加载类。一个使用 Spring Web、Spring Data 和一两个驱动程序的普通 REST 服务,在响应第一个请求之前要加载一万到两万个类。每加载一个类,JVM 都要执行同样的仪式:在 jar 包中找到类文件,读取字节,解析,验证字节码合法,并构建运行所需的内部元数据结构。这个过程要重复数千次。每次启动都要重复。每个 Pod 里都要重复。

接下来是真正值得你在意的地方。你的容器镜像一旦构建,就永远不会发生变化。同样的 jar 包、同样的类、同样的解析工作,在每一个从该镜像启动的 Pod 中被一模一样地反复执行。JVM 一遍又一遍地解决同一个谜题,然后把答案丢掉了。而 CDS 的做法是:让我只解一次题,把答案写到文件中,下次启动时直接通过内存映射加载该文件即可。

什么是 CDS 归档文件

CDS 归档文件是一个通常以 .jsa 结尾的文件,里面保存着已经完成解析和验证的类,并以 JVM 在内存中使用的精确内部格式存储。启动时,JVM 会把这个文件直接映射到内存中。不需要查找,不需要解析,不需要验证。所有工作都已提前完成。

其实你早就用过 CDS,只是自己没意识到。现代 JDK 自带的默认归档文件覆盖了 JDK 核心类,所以 java -version 才会这么快。几乎所有人都会跳过的一步,是为你的应用类创建归档文件——那可是一万五千个类。真正的收益就在那里。

这套机制有一条规则对我们很重要:归档文件必须由使用它的同一个 JVM 和同一条 classpath 来创建。这条规则听起来很麻烦,直到你意识到 Docker 镜像是整个基础设施中唯一一个让 JVM 和 classpath 被永久冻结的地方。Docker 不只是兼容 CDS,它简直就是 CDS 的理想容器。

训练运行

创建归档文件需要两步。首先做一次训练运行:JVM 启动你的应用,观察哪些类被加载了,并把清单写下来。然后你退出应用,JVM 会把这份清单转化成归档文件。从 Java 13 开始,这一步简单得令人愉快:

$ bash
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar

运行应用,让它启动完成,再停下来,然后 app.jsa 就出现了。之后你可以这样启动应用:

$ bash
java -XX:SharedArchiveFile=app.jsa -jar app.jar

这里有一个显而易见的问题:训练运行需要真正启动应用,但在 docker build 环境中没有数据库、没有消息代理,什么外部依赖都连不上。一个无法连接 Postgres 的 Spring Boot 应用会在训练过程中崩溃。Spring Boot 3.3 很优雅地解决了这个问题。设置一个属性,应用就会跑完整个启动流程、创建所有 bean 定义,然后在即将接触外部世界之前退出:

$ bash
java -Dspring.context.exit=onRefresh -XX:ArchiveClassesAtExit=app.jsa -jar app.jar

应用几乎加载了全部它将来会加载的类,写好了归档文件,然后干净利落地退出,完全不需要任何基础设施。这正是 Docker 构建阶段能做到的事。

Dockerfile

下面是完整的方案:一个让镜像自我训练的多阶段构建。

$ dockerfile
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /build
COPY . .
RUN ./mvnw -B package -DskipTests

# Explode the jar so the classpath is stable
RUN java -Djarmode=tools -jar target/app.jar extract --destination /app

FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY --from=build /app /app

# Training run: start the context, record classes, exit
RUN java -Dspring.context.exit=onRefresh \
    -XX:ArchiveClassesAtExit=/app/app.jsa \
    -jar /app/app.jar

ENV JAVA_TOOL_OPTIONS="-XX:SharedArchiveFile=/app/app.jsa"
ENTRYPOINT ["java", "-jar", "/app/app.jar"]

其中有两个细节值得仔细看看。extract 这一步会把 fat jar 解包成一个文件夹,把所有依赖都展开成普通文件。CDS 对训练阶段和真实运行阶段的 classpath 是否完全一致非常挑剔,而包含嵌套 jar 的 fat jar 会破坏这种稳定性。展开后的目录结构让 classpath 保持简单、稳定,这正是 CDS 想要的。

在 Spring Boot 3.2 及更早版本中,同样可以用 layertools jarmode 来实现。训练运行是一条 RUN 指令,意味着它只在构建阶段的 CI 服务器上执行一次。从该镜像启动的任何容器都会免费继承这个归档文件。你在构建过程中一次性完成了类加载的功课,然后成千上万次 Pod 启动都在复制这个答案。

你能得到什么

具体数字会因你的应用负载轻重而有所不同,但规律是一致的。一个原先需要 10 到 12 秒启动的典型 Spring Boot 3 Web 服务,现在会降到 5 到 7 秒。JVM 消耗的启动时间会大幅缩减。额外的好处是,归档文件被内存映射且可以被共享:如果同一节点上运行多个 JVM,它们会共享这些内存页,总内存占用也会下降。

你可以验证归档文件是否真的被使用了,这也正是我推荐的,因为 CDS 的设计就是静默失败。如果出现不匹配,它会悄悄回退到普通类加载流程:

$ bash
docker run --rm my-service -Xlog:class+load=info | head -5

从归档文件中加载的类,日志会显示 source: shared objects file。如果你看到的是 jar 路径,说明归档文件被忽略了,日志通常会告诉你原因。最常见的元凶就是 classpath 与训练时不一致,哪怕只差一个条目。

还有一个诚实的提醒:训练运行覆盖的是启动过程,而不是你的实际流量。某些类只有在某个特定端点第一次被访问时才会加载,这些类不会出现在归档文件里,因此那第一批请求仍然会经历正常的类加载过程。CDS 覆盖的是框架和应用装配,而这已经占了启动成本的大头;但它并不是万能的热身工具。

为什么这比你听过的替代方案更好

每当有人谈起容器启动时间,总会提到 GraalVM 原生镜像。原生镜像确实令人印象深刻,毫秒级启动是真实的。但它是带价签的:构建时间长,封闭世界假设与反射机制互相冲突,有些库根本无法使用,而且你还要学习一套不同的运行时排查方式。CDS 只需要你在 Dockerfile 里加五行代码。你的应用仍完全是普通的 JVM 应用:同样的调试工具、同样的性能分析器、同样的类库、同样的行为,只是启动更快了。对大多数团队来说,这笔权衡根本不需要犹豫。

CDS 还能和未来的方向叠加。Project Leyden 在 Java 24 及以后版本中引入的 AOT 缓存,本质上就是同一思路的进阶版:不仅缓存已经解析的类,还缓存解析后的链接关系和编译代码。你今天构建的这套 Dockerfile 模式——在构建时训练,生成缓存文件并随镜像发布——正是 Leyden 所采用的形态。现在学会了,以后只需要改一个参数就行。

结论

你的 Docker 镜像是不可变的。你的 JVM 每次启动都在做昂贵且完全可以重复的工作。这两个事实就像两块拼图一样严丝合缝,而 docker build 中的训练运行正是它们的连接点。多一步构建步骤,你的自动扩缩容之后创建的每一个 Pod 都能用一半时间启动。下次当你看着滚动发布因为 Pod 迟迟不就绪而慢慢爬行时,请记住答案一直就藏在构建过程中。

DZone 贡献者所表达的观点属于其个人观点。