针对 Cloud Run 优化 Java 应用

本指南介绍了对使用 Java 编程语言编写的 Cloud Run 服务的优化以及有助于您了解某些优化所涉及的权衡的背景信息。此页面上的信息是对常规优化提示的补充,这些提示同样适用于 Java。

传统的 Java Web 应用旨在高并发和低延迟地处理请求,并且通常为长时间运行的应用。JVM 自身还会通过 JIT 逐渐优化执行代码,以使热路径得到优化,并使应用运行更加高效。

这些传统的 Java Web 应用中的许多最佳做法和优化都围绕着以下内容:

  • 处理并发请求(基于线程的 I/O 和非阻塞 I/O)
  • 通过使用连接池和批处理非关键函数减少响应延迟时间,例如将跟踪记录和指标发送到后台任务。

许多传统优化非常适合于长时间运行的应用,但对于 Cloud Run 服务可能效果不佳,后者仅在主动处理请求时运行。本页面介绍了一些不同的 Cloud Run 优化和权衡,可用于减少启动时间和内存用量。

使用启动 CPU 加速功能缩短启动延迟时间

您可以启用启动 CPU 加速,在实例启动期间临时增加 CPU 分配,以缩短启动延迟时间。

Google 的指标表明,如果使用启动 CPU 加速,Java 应用将受益,此功能可将启动时间缩短最多 50%。

优化容器映像

通过优化容器映像,您可以缩短加载时间和启动时间。您可以通过以下方式优化映像:

  • 尽可能减小化容器映像大小
  • 避免使用嵌套库归档 JAR
  • 使用 Jib

尽可能减小容器映像大小

请参阅有关尽可能减小容器映像大小的常规提示页面,了解此问题的更多背景信息。常规提示页面建议将容器映像大小减少为仅包含必需的内容。例如,确保容器映像不包含

  • 源代码
  • Maven 构建工件
  • 构建工具
  • Git 目录
  • 未使用的二进制文件/实用程序

如果您是从 Dockerfile 内构建代码,请使用 Docker 多阶段构建,以使最终容器映像仅具有 JRE 和应用 JAR 文件本身。

避免嵌套库归档 JAR

一些流行的框架(如 Spring Boot)会创建一个应用归档 (JAR) 文件,其中包含其他库 JAR 文件(嵌套 JAR)。这些文件需要在启动期间解压缩,这可能会对 Cloud Run 中的启动速度产生负面影响。因此,尽可能使用外部化的库创建精简 JAR:这可以通过使用 Jib 将应用容器化来自动完成。

使用 Jib

您可以使用 Jib 插件创建最小容器并自动展平应用归档。Jib 同时支持 Maven 和 Gradle,并且可以为 Spring Boot 应用提供开箱即用的支持。某些应用框架可能需要额外的 Jib 配置。

JVM 优化

优化 Cloud Run 服务的 JVM 可以提高性能和内存使用率。

使用容器感知的 JVM 版本

在虚拟机和机器中,对于 CPU 和内存分配,JVM 会从常见位置(例如,Linux 中的 /proc/cpuinfo/proc/meminfo)查找其可以使用的 CPU 和内存。但是,在容器中运行时,CPU 和内存限制条件存储在 /proc/cgroups/... 中。较旧版本的 JDK 会继续在 /proc(而不是 /proc/cgroups)中查找,这可能会导致 CPU 和内存用量超出分配的上限。这可能会导致:

  • 线程过多,因为线程池大小由 Runtime.availableProcessors() 配置
  • 超出容器内存上限的默认最大堆。JVM 在进行垃圾回之前大量使用内存。这很容易导致容器超出容器内存限制,并导致 OOMKilled。

因此,请使用容器感知的 JVM 版本。默认情况下,大于或等于 8u192 的 OpenJDK 版本是容器感知的。

如何了解 JVM 内存用量

Java 内存用量由本机内存用量和堆用量组成。应用的工作内存通常位于堆中。堆的大小受最大堆配置的限制。使用 Cloud Run 256MB RAM 实例时,您无法将所有 256 MB 分配给最大堆,因为 JVM 和操作系统也需要本机内存,例如线程栈、代码缓存、文件处理程序、缓冲区等。如果应用发生 OOMKilled,并且您需要了解 JVM 内存用量(原生内存 + 堆),请开启 Native Memory Tracking,以便在应用成功退出时查看用量。如果应用发生 OOMKilled,则无法打印信息。在这种情况下,请先使用更多内存运行应用,以便它可以成功生成输出。

您无法通过 JAVA_TOOL_OPTIONS 环境变量开启 Native Memory Tracking。您需要将 Java 命令行启动参数添加到容器映像入口点,以便您的应用使用以下参数启动应用:

java -XX:NativeMemoryTracking=summary \
  -XX:+UnlockDiagnosticVMOptions \
  -XX:+PrintNMTStatistics \
  ...

可以根据要加载的类的数量来估算本机内存用量。请考虑使用开源 Java 内存计算器来估算内存需求。

关闭优化编译器

默认情况下,JVM 有多个阶段的 JIT 编译。虽然这些阶段可以逐渐提高应用的效率,但它们也会增加内存使用的开销,并增加启动时间。

对于短期运行的无服务器应用(例如函数),请考虑关闭优化阶段,以牺牲长期效率换取更短的启动时间。

对于 Cloud Run 服务,请配置以下环境变量:

JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"

使用应用类数据共享

如需进一步减少 JIT 时间和内存用量,请考虑使用应用类数据共享 (AppCDS) 来共享预先编译的 Java 类归档。启动同一 Java 应用的另一个实例时,可以重复使用 AppCDS 归档。JVM 可以重复使用归档中预先计算的数据,从而缩短启动时间。

使用 AppCDS 时需注意以下几点:

  • 要重复使用的 AppCDS 归档必须使用最初生成它的 OpenJDK 发行版、版本和架构来重现。
  • 您必须至少运行一次应用以生成要共享的类列表,然后使用该列表生成 AppCDS 归档。
  • 类的范围取决于应用运行期间执行的代码路径。为扩大范围,采用编程方式可以触发更多代码路径。
  • 应用必须成功退出才能生成此类列表。请考虑实现用于表示 AppCDS 归档生成的应用标志,以便立即退出。
  • 只有使用与生成 AppCDS 归档完全相同的方式来启动新实例,才能重复使用该归档。
  • AppCDS 归档仅适用于常规 JAR 文件包;不支持使用嵌套 JAR。

使用阴影 JAR 文件的 Spring Boot 示例

Spring Boot 应用默认使用嵌套超级 JAR,这不适用于 AppCDS。因此,如果您要使用 AppCDS,则需要创建一个阴影 JAR。例如,使用 Maven 和 Maven Shade 插件:

<build>
  <finalName>helloworld</finalName>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <configuration>
        <keepDependenciesWithProvidedScope>true</keepDependenciesWithProvidedScope>
        <createDependencyReducedPom>true</createDependencyReducedPom>
        <filters>
          <filter>
            <artifact>*:*</artifact>
            <excludes>
              <exclude>META-INF/*.SF</exclude>
              <exclude>META-INF/*.DSA</exclude>
              <exclude>META-INF/*.RSA</exclude>
            </excludes>
          </filter>
        </filters>
      </configuration>
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <transformers>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.handlers</resource>
              </transformer>
              <transformer implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer">
                <resource>META-INF/spring.factories</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.schemas</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>${mainClass}</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

如果您的阴影 JAR 包含所有依赖项,您可以在容器构建期间使用 Dockerfile 生成简单归档:

# Use Docker's multi-stage build
FROM eclipse-temurin:11-jre as APPCDS

COPY target/helloworld.jar /helloworld.jar

# Run the application, but with a custom trigger that exits immediately.
# In this particular example, the application looks for the '--appcds' flag.
# You can implement a similar flag in your own application.
RUN java -XX:DumpLoadedClassList=classes.lst -jar helloworld.jar --appcds=true

# From the captured list of classes (based on execution coverage),
# generate the AppCDS archive file.
RUN java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=appcds.jsa --class-path helloworld.jar

FROM eclipse-temurin:11-jre

# Copy both the JAR file and the AppCDS archive file to the runtime container.
COPY --from=APPCDS /helloworld.jar /helloworld.jar
COPY --from=APPCDS /appcds.jsa /appcds.jsa

# Enable Application Class-Data sharing
ENTRYPOINT java -Xshare:on -XX:SharedArchiveFile=appcds.jsa -jar helloworld.jar

减小线程栈大小

大多数 Java Web 应用都是基于每个连接一个线程的模式。每个 Java 线程都会消耗本机内存(而不是堆内存)。这称为线程栈,并且每个线程默认为 1 MB。如果您的应用处理 80 个并发请求,则它可能至少有 80 个线程,这相当于使用了 80 MB 的线程栈空间。该内存不计入堆大小。默认值可能大于必要值。您可以减小线程栈大小。

如果减小得太多,则将出现 java.lang.StackOverflowError。您可以对应用进行分析,并找到要配置的最佳线程栈大小。

对于 Cloud Run 服务,请配置以下环境变量:

JAVA_TOOL_OPTIONS="-Xss256k"

减少线程

您可以通过使用非阻塞反应式策略和避免后台活动来减少线程数量,从而优化内存。

减少线程数量

由于线程栈,每个 Java 线程都可能会增加内存用量。Cloud Run 允许最多 1000 个并发请求。使用每个连接一个线程模式时,您最多需要 1000 个线程来处理所有并发请求。大多数 Web 服务器和框架都允许您配置线程数和连接数上限。例如,在 Spring Boot 中,您可以在 applications.properties 文件中设置最大连接数:

server.tomcat.max-threads=80

编写非阻塞反应式代码以优化内存和启动

要真正减少线程数量,请考虑采用非阻塞反应式编程模型,以便在处理更多并发请求时可以显著减少线程数量。Spring Boot Webflux、MicrosoftNavt 和 Quarkus 等应用框架支持反应式 Web 应用。

Spring Boot Webflux、Micronaut、Quarkus 等反应式框架通常具有更快的启动时间。

如果您继续在非阻塞框架中写入阻塞代码,则 Cloud Run 服务中的吞吐量和错误率会显著恶化。这是因为非阻塞框架将只有几个线程,例如 2 或 4。如果您的代码被阻塞,则仅可以处理极少的并发请求。

这些非阻塞框架还可以将阻塞代码分流到无界限线程池,这意味着,虽然它可以接受许多并发请求,但阻塞代码将在新线程中执行。如果线程以无界限的方式累积,则会耗尽 CPU 资源并开始抖动。延迟时间将受到严重影响。如果您使用非阻塞框架,请务必了解线程池模型并相应地绑定池。

使用后台活动时,将 CPU 配置为始终分配

后台活动是指在 HTTP 响应送达后发生的任何活动。具有传统任务的传统工作负载在 Cloud Run 中运行时需要特别注意。

将 CPU 配置为始终分配

如果您希望在 Cloud Run 服务中支持后台活动,请将 Cloud Run 服务 CPU 设置为始终分配,以便您可以在请求之外运行后台活动,并且仍拥有 CPU 访问权限。

如果仅在处理请求期间分配 CPU,请避免进行后台活动

如果您需要将服务设置为仅在处理请求期间分配 CPU,则需要注意后台活动存在的潜在问题。例如,如果您要收集应用指标并在后台批处理指标以进行定期发送,则在未分配 CPU 时,这些指标不会发送。如果您的应用不断收到请求,您可能会看到较少的问题。如果您的应用具有较低的 QPS,则后台任务可能永远不会执行。

如果您选择仅在处理请求期间分配 CPU,以下是您需要注意的一些在后台运行的常见模式:

  • JDBC 连接池 - 清理和连接检查在后台进行
  • 分布式跟踪记录发送器 - 分布式跟踪记录通常会定期或在后台缓冲区已满时进行批处理和发送。
  • 指标发送器 - 指标通常会在后台进行批量处理和发送。
  • 对于 Spring Boot,任何带有 @Async 注释的方法
  • 计时器 - 任何基于计时器的触发器(例如,ScheduledThreadPoolExecutor、Quartz 或 @Scheduled Spring 注释)可能无法在未分配 CPU 时执行。
  • 消息接收器 - 例如,Pub/Sub 流式拉取客户端、JMS 客户端或 Kafka 客户端,通常在后台线程中运行,无需请求。当您的应用没有请求时,它们将不起作用。在 Cloud Run 中不建议以这种方式接收消息。

应用优化

在 Cloud Run 服务代码中,您也可以进行优化以减少启动时间和内存用量。

减少启动任务

传统的 Java Web 应用会在启动期间完成许多任务,例如预加载数据、预热缓存、建立连接池等。依次执行这些任务会很慢。但是,如果您希望它们并行执行,则应增加 CPU 核心数。

Cloud Run 目前会发送一个实际用户请求以触发冷启动实例。其请求被分配到新启动实例的用户可能会遇到较长的延迟。Cloud Run 目前没有“就绪”检查来避免向未就绪的应用发送请求。

使用连接池

如果您使用连接池,请注意,连接池可能会在后台逐出不需要的连接(请参阅避免后台任务)。如果应用的 QPS 较低,并且可以容忍高延迟,请考虑为每个请求打开和关闭连接。如果应用的 QPS 较高,则只要存在活跃请求,后台逐出就可能会继续执行。

在这两种情况下,应用的数据库访问都将在数据库允许的连接数上限方面遭遇瓶颈。计算每个 Cloud Run 实例可建立的最大连接数,并配置 Cloud Run 实例数上限,以使实例数上限与每个实例的连接数的乘积小于允许的连接数上限。

如果您使用 Spring Boot

如果您使用 Spring Boot,则需要考虑以下优化

使用 Spring Boot 2.2 或更高版本

从 2.2 版开始,Spring Boot 已针对启动速度进行了大量优化。如果您使用的是低于 2.2 版的 Spring Boot,请考虑升级或手动应用各项优化

使用延迟初始化

在 Spring Boot 2.2 及更高版本中,可以开启一个全局延迟初始化标志。这将提高启动速度,但代价是第一个请求的延迟时间可能变长,因为需要等待组件首次初始化。

您可以在 application.properties 中开启延迟初始化:

spring.main.lazy-initialization=true

或者,使用以下环境变量:

SPRING_MAIN_LAZY_INITIALIZATIION=true

但是,如果您使用的是 min-instances,由于 min-instance 启动时应已执行了初始化,因此延迟初始化没有什么用处。

避免类扫描

类扫描会在 Cloud Run 中导致额外的磁盘读取,因为在 Cloud Run 中,磁盘访问速度通常比常规机器慢。请确保进行有限的组件扫描或完全不进行组件扫描。考虑使用 Spring Context Indexer 来预生成索引。这是否会提高启动速度取决于您的应用。

例如,在 Maven pom.xml 中添加索引器依赖项(实际上是注释处理器):

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context-indexer</artifactId>
  <optional>true</optional>
</dependency>

使用不在生产中使用的 Spring Boot 开发者工具

如果您在开发过程中使用 Spring Boot 开发者工具,请确保未将其打包到生产容器映像中。如果您在没有 Spring Boot 构建插件(例如,使用 Shade 插件或使用 Jib 进行容器化)的情况下构建 Spring Boot 应用,则可能会发生这种情况。

在这种情况下,请确保构建工具明确排除 Spring Boot 开发者工具。或者,明确关闭 Spring Boot 开发者工具

后续步骤

如需查看更多提示,请参阅以下内容