「谷歌实践」Spring Boot 微服务容器化优化
译者注
本篇文章虽然介绍的是 Google Cloud Run 中的 java 优化,大部分建议对于docker
、k8s
等容器化同样适用,希望对大家有所帮助。
前言
本指南介绍了谷歌对使用 Java 编写的 Cloud Run 服务的优化,将帮助你了解 Spring Boot 常见优化方式。本文内容是对常规优化提示[1]的补充,这些建议同样适用于传统 Java 应用。
传统的 Java 网页应用优化旨在高并发和低延迟地处理请求,并稳定的长期运行应用。JVM 自身还会通过JIT 优化执行代码,使热点代码得到优化,并使应用运行更加高效。
这些传统的 Java Web 应用中的许多最佳做法和优化都围绕着以下内容:
处理并发请求(基于线程的 I/O 和非阻塞 I/O) 通过使用连接池和后台任务减少响应延迟时间,例如将跟踪记录和指标发送到后台任务。
许多传统优化非常适合于长时间运行的应用,但对于 Cloud Run 服务可能效果不佳,后者仅在主动处理请求时运行。本页面介绍了一些不同的 Cloud Run 优化和权衡,可用于减少启动时间和内存使用量。
优化容器镜像
通过优化容器镜像,您可以缩短加载时间和启动时间。您可以通过以下方式优化镜像:
尽可能减小容器镜像大小 避免使用依赖压缩 JAR 使用 Jib 构建
尽可能减小容器镜像大小
请阅读《如何优化构建镜像》[2]了解关于此问题的详细解决方案。总结下来优化套路如下:
确保容器镜像不包含:
源代码 Maven 构建工件 构建工具 Git 目录 未使用的二进制文件/程序
对于基础镜像,请考虑将Distroless Java 基础镜像[3]用于最简 Java 基础镜像,这也是使用 Jib 构建容器镜像[4]时选择的默认基础镜像。
如果您是从Dockerfile
内构建代码,请使用Docker
多阶段构建,以使最终容器镜像仅具有 JRE 和应用 JAR 文件本身。
避免使用依赖压缩 JAR
一些流行的框架(如 Spring Boot)会创建一个应用 (JAR) 文件,其中包含其他库 JAR 文件(依赖 JAR)。这些文件需要在启动期间解压缩,影响 Cloud Run 启动速度。所以建议通过 jib 自动化创建精简 JAR 来构建镜像。
译者注:同样也可以使用Spring boot 2.3 的新特性分层 JAR[5]。
使用 Jib 构建
您可以使用Jib 插件[6]创建最小容器并自动解压应用依赖。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,以便在应用异常退出时查看使用量。
注意:无法直接通过JAVA_TOOL_OPTIONS
环境变量开启 Native Memory Tracking。您需要将 Java 命令行启动参数添加到容器镜像入口点,以便您的应用使用以下参数启动应用:
java -XX:NativeMemoryTracking=summary \
-XX:+UnlockDiagnosticVMOptions \
-XX:+PrintNMTStatistics \
...
可以根据要加载的类的数量来估算本机内存用量。请考虑使用开源Java 内存计算器[7]来估算内存需求。译者注:同样也可以使用 alibaba 开源的 arthas[8]
关闭优化编译器
默认情况下,JVM 有多个阶段的 JIT 编译。虽然这些阶段可以逐渐提高应用的效率,但它们也会增加内存使用的开销,并增加启动时间。
对于短期运行的serverless(无服务)应用(例如函数),请考虑关闭此优化,以牺牲长期效率换取更短的启动时间。
对于 Cloud Run 服务,请配置以下环境变量:
JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
关闭类加载验证
当 JVM 将类加载到内存中以供执行时,它会验证该类未被篡改并且没有恶意修改或损坏。如果您完全信任容器镜像中的字节码,并且您的应用未从任意远程来源加载类,则您可以考虑关闭验证。如果在启动时加载大量类,则关闭验证可能会提高启动速度。
对于 Cloud Run 服务,请配置以下环境变量:
JAVA_TOOL_OPTIONS="-noverify"
注意:此选项已被 OpenJDK 13 及更高版本弃用[9]。
减小线程栈大小
大多数 Java Web 应用都是基于每个连接一个线程的模式。每个 Java 线程都会消耗本机内存(而不是堆内存)。这称为线程栈,并且每个线程默认为 1 MB。如果您的应用处理 80 个并发请求,则它可能至少有 80 个线程,这相当于使用了 80 MB 的线程栈空间。该内存不计入堆大小。默认值可能大于必要值。您可以减小线程栈大小。
如果减小得太多,则将出现java.lang.StackOverflowError
。您可以对应用进行分析,并找到要配置的最佳线程栈大小。
对于 Cloud Run 服务,请配置以下环境变量:
JAVA_TOOL_OPTIONS="-Xss256k"
减少线程
您可以通过使用非阻塞反应式和避免无效后台进程来减少线程数量,从而优化内存。
减少线程数量
由于线程栈,每个 Java 线程都可能会增加内存用量。Cloud Run 允许最多 80 个并发请求。使用每个连接一个线程模式时,您最多需要 80 个线程来处理所有并发请求。大多数 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 资源并开始抖动。延迟时间将受到严重影响。
译者注:如果使用非阻塞框架,请务必了解线程池模型,不合理的代码将会带来灾难。
避免后台活动进程
当该实例不再收到请求时,Cloud Run 会限制实例 CPU[10]。具有传统任务的传统工作负载在 Cloud Run 中运行时需要特别注意。
例如,如果您要收集应用指标并在后台批处理指标以进行定期发送,则在 CPU 受到限制时,这些指标不会发送。如果您的应用不断收到请求,您可能会看到较少的问题。如果您的应用具有较低的 QPS,则后台任务可能永远不会执行。
以下是您需要注意的一些在后台运行的常见模式:
JDBC 连接池 - 清理和连接检查在后台进行 分布式跟踪记录发送器 - 分布式跟踪记录通常会定期或在后台缓冲区已满时进行批处理和发送。 指标发送器 - 指标通常会在后台进行批量处理和发送。 对于 Spring Boot,任何带有 @Async
注释的方法计时器 - 任何基于计时器的触发器(例如,ScheduledThreadPoolExecutor、Quartz 或 @Scheduled
Spring 注释)可能无法在 CPU 受到限制时执行。消息接收器 - 例如,Pub/Sub 流式拉取客户端、JMS 客户端或 Kafka 客户端,通常在后台线程中运行,无需请求。当您的应用没有请求时,它们将不起作用。在 Cloud Run 中不建议以这种方式接收消息。
应用优化
在 Cloud Run 服务代码中,您也可以进行优化以减少启动时间和内存使用量。
减少启动任务
传统的 Java Web 应用会在启动期间完成许多任务,例如预加载数据、预热缓存、建立连接池等。依次执行这些任务会很慢。但是,如果您希望它们并行执行,则应增加 CPU 核心数。
Cloud Run 目前会发送一个实际用户请求以触发冷启动实例。其请求被分配到新启动实例的用户可能会遇到较长的延迟。Cloud Run 目前没有“就绪”检查来避免向未就绪的应用发送请求。
使用连接池
如果您使用连接池,请注意,连接池可能会在后台逐出不需要的连接(请参阅避免后台任务[11])。如果应用的 QPS 较低,并且可以容忍高延迟,请考虑为每个请求打开和关闭连接。如果应用的 QPS 较高,则只要存在活跃请求,后台就可能会继续执行。
在这两种情况下,应用的数据库访问都将在数据库允许的连接数上限方面遭遇瓶颈。计算每个 Cloud Run 实例可建立的最大连接数,并配置 Cloud Run 实例数上限[12],以使实例数上限与每个实例的连接数的乘积小于允许的连接数上限。
使用 Spring Boot
如果您使用 Spring Boot,则需要考虑以下优化
使用 Spring Boot 2.2 或更高版本
从 2.2 版开始,Spring Boot 已针对启动速度进行了大量优化。如果您使用的是低于 2.2 版的 Spring Boot,请考虑升级或手动应用各项优化[13]。
使用延迟初始化
在 Spring Boot 2.2 及更高版本中,可以开启一个全局延迟初始化标志。这将提高启动速度,但代价是第一个请求的延迟时间可能变长,因为需要等待组件首次初始化。
您可以在application.properties
中开启延迟初始化:
spring.main.lazy-initialization=true
或者,使用以下环境变量:
SPRING_MAIN_LAZY_INITIATIALIZATION=true
但是,如果您使用的是 min-instances,由于 min-instance 启动时应已执行了初始化,因此延迟初始化没有什么用处。
避免类扫描
类扫描会在 Cloud Run 中导致额外的磁盘读取,因为在 Cloud Run 中,磁盘访问速度通常比常规机器慢。请确保进行有限的组件扫描或完全不进行组件扫描。考虑使用Spring Context Indexer
来预生成索引。这是否会提高启动速度取决于您的应用。
例如,在 Mavenpom.xml
中添加索引器依赖项(实际上是注释处理器):
<dependency>
<groupId>org.springframeworkgroupId>
<artifactId>spring-context-indexerartifactId>
<optional>trueoptional>
dependency>
不要在生产环境使用 Spring Boot Devtools
如果您在开发过程中使用Spring Boot Devtools[14],请确保未将其打包到生产容器镜像中。如果您在没有 Spring Boot 构建插件(例如,使用 Shade 插件或使用 Jib 进行容器化)的情况下构建 Spring Boot 应用,则可能会发生这种情况。
在这种情况下,请确保构建工具排除 Spring Boot Devtools。或者关闭 Spring Boot Devtools[15]。
后续步骤
如需获得更多提示,请参阅
如何高效的进行 Java 优化[16] 如何迁移现有服务[17]翻译:冷冷、如梦技术(DreamLu)
原文链接:https://cloud.google.com/run/docs/tips/java#appcds[18]
参考资料
常规优化提示: https://cloud.google.com/run/docs/tips
[2]《如何优化构建镜像》: https://cloud.google.com/run/docs/tips#minimize-container
[3]Distroless Java 基础: https://github.com/GoogleContainerTools/distroless/tree/master/java
[4]使用 Jib 构建容器: https://cloud.google.com/java/getting-started/jib
[5]Spring boot 2.3 的新特性分层 JAR: https://juejin.im/post/6844904167710916615
[6]Jib 插件: https://github.com/GoogleContainerTools/jib
[7]Java 内存计算器: https://github.com/cloudfoundry/java-buildpack-memory-calculator
[8]arthas: https://alibaba.github.io/arthas/
[9]此选项已被 OpenJDK 13 及更高版本弃用: https://www.oracle.com/java/technologies/javase/13all-relnotes.html
[10]限制实例 CPU: https://cloud.google.com/run/docs/reference/container-contract#cpu-request
[11]避免后台任务: https://cloud.google.com/run/docs/tips/java#background
[12]配置 Cloud Run 实例数上限: https://cloud.google.com/run/docs/configuring/max-instances
[13]手动应用各项优化: https://spring.io/blog/2018/12/12/how-fast-is-spring
[14]Spring Boot Devtools: https://docs.spring.io/spring-boot/docs/current/reference/html/using-spring-boot.html#using-boot-devtools
[15]关闭 Spring Boot Devtools: https://docs.spring.io/spring-boot/docs/current/reference/html/using-spring-boot.html#using-boot-devtools
[16]如何高效的进行 Java 优化: https://cloud.google.com/run/docs/tips/general
[17]如何迁移现有服务: https://cloud.google.com/run/docs/migrating
[18]https://cloud.google.com/run/docs/tips/java#appcds: https://cloud.google.com/run/docs/tips/java#appcds