Optimiza las aplicaciones de Java para Cloud Run

En esta guía, se describen las optimizaciones para los servicios de Cloud Run escritos en el lenguaje de programación Java, junto con información general para ayudarte a comprender las ventajas y desventajas de algunas de las optimizaciones. La información de esta página complementa las sugerencias de optimización generales que también se aplican a Java.

Las aplicaciones tradicionales basadas en la Web de Java están diseñadas para entregar solicitudes con alta simultaneidad y baja latencia, y suelen ser aplicaciones de larga duración. La JVM también optimiza el código de ejecución con JIT, de modo que las rutas activas se optimizan y las aplicaciones se ejecutan de manera más eficiente con el tiempo.

Muchas de las prácticas recomendadas y optimizaciones de esta aplicación tradicional de Java basada en la Web giran en torno a lo siguiente:

  • Cómo manejar solicitudes simultáneas (de E/S basadas en subprocesos y sin bloqueo)
  • Cómo reducir la latencia de respuesta mediante la agrupación de conexiones y el procesamiento por lotes de funciones no críticas, por ejemplo, el envío de seguimientos y métricas a tareas en segundo plano.

Si bien muchas de estas optimizaciones tradicionales funcionan bien para aplicaciones de larga duración, es posible que no funcionen tan bien en un servicio de Cloud Run, que se ejecuta solo cuando se entregan solicitudes de forma activa. En esta página, se muestran algunas optimizaciones y compensaciones diferentes para Cloud Run que puedes usar a fin de reducir el tiempo de inicio y el uso de la memoria.

Usa el aumento de la CPU de inicio para reducir la latencia del inicio

Puedes habilitar el aumento de CPU de inicio para aumentar de forma temporal la asignación de CPU durante el inicio de la instancia a fin de reducir la latencia de inicio.

Las métricas de Google muestran que las apps de Java se benefician si usan el aumento de CPU de inicio, lo que puede reducir los tiempos de inicio hasta en un 50%.

Optimiza la imagen del contenedor

Al optimizar la imagen del contenedor, puedes reducir los tiempos de carga y de inicio. Puedes optimizar la imagen mediante los siguientes métodos:

  • Al minimizar la imagen del contenedor
  • Al evitar el uso de archivos JAR de bibliotecas anidadas
  • Usa Jib

Minimiza la imagen de contenedor

Consulta la página de sugerencias generales sobre cómo minimizar contenedores a fin de obtener más contexto sobre este tema. En la página de sugerencias generales, se recomienda reducir el contenido de la imagen del contenedor solo a lo necesario. Por ejemplo, asegúrate de que tu imagen de contenedor no contenga:

  • Código fuente
  • Artefactos de compilación de Maven
  • Herramientas de compilación
  • Directorios de Git
  • Objetos binarios o utilidades sin uso

Si compilas el código desde un Dockerfile, usa la compilación de varias etapas de Docker para que la imagen del contenedor final solo tenga el JRE y el archivo JAR de la aplicación.

Evita los archivos JAR anidados de la biblioteca

Algunos frameworks populares, como Spring Boot, crean un archivo de aplicación (JAR) que contiene archivos JAR de bibliotecas adicionales (JAR anidados). Estos archivos deben descomprimirse durante el tiempo de inicio, lo que puede afectar de forma negativa la velocidad de inicio en Cloud Run. Por eso, cuando sea posible, crea un JAR delgado con bibliotecas externas. Puedes automatizar este proceso si usas Jib para organizar tu aplicación en contenedores.

Usa Jib

Usa el complemento de Jib para crear un contenedor mínimo y compactar el archivo de la aplicación de forma automática. Jib funciona con Maven y Gradle, y con las aplicaciones Spring Boot listas para usar. Algunos frameworks de aplicaciones pueden requerir configuraciones de Jib adicionales.

Optimizaciones de JVM

La optimización de JVM para un servicio de Cloud Run puede mejorar el rendimiento y el uso de la memoria.

Usa versiones de JVM con reconocimiento de contenedores

En VM y máquinas, para asignaciones de CPU y memoria, la JVM está al tanto de la CPU y la memoria que puede usar desde ubicaciones conocidas, por ejemplo, en Linux, /proc/cpuinfo y /proc/meminfo. Sin embargo, cuando se ejecuta en un contenedor, las restricciones de CPU y memoria se almacenan en /proc/cgroups/.... La versión anterior del JDK continúa buscando en /proc, en lugar de /proc/cgroups, lo que puede generar más uso de CPU y memoria de lo que se asignó. Esto puede causar lo siguiente:

  • Una cantidad excesiva de subprocesos porque Runtime.availableProcessors() configura el tamaño del conjunto de subprocesos
  • Un montón máximo predeterminado que excede el límite de memoria del contenedor. La JVM usa la memoria de forma agresiva antes de la recolección de elementos no utilizados. Esto puede hacer que el contenedor supere el límite de memoria del contenedor y obtener OOMKilled.

Por lo tanto, usa una versión de JVM con reconocimiento de contenedores. Las versiones de OpenJDK posteriores o equivalentes a la versión 8u192 reconocen el contenedor de forma predeterminada.

Cómo comprender el uso de memoria de JVM

El uso de memoria de JVM se compone de uso de memoria nativa y del uso del montón. La memoria de trabajo de tu aplicación suele estar en el montón. El tamaño del montón está limitado por la configuración de montón máxima. Con una instancia de RAM de 256 MB de Cloud Run, no puedes mapear todos los 256 MB al montón máximo, ya que la JVM y el SO también requieren memoria nativa, por ejemplo, pila de subprocesos, cachés de código, controladores de archivos, búferes, etc. Si tu aplicación recibe OOMKilled y necesitas saber el uso de memoria de la JVM (memoria nativa + montón), activa el seguimiento de memoria nativa para ver los usos cuando se cierra la aplicación de forma correcta. Si tu aplicación recibe OOMKilled, no podrá imprimir la información. En ese caso, ejecuta la aplicación con más memoria primero para que pueda generar el resultado de forma correcta.

El seguimiento de memoria nativa no se puede activar a través de la variable de entorno JAVA_TOOL_OPTIONS. Debes agregar el argumento de inicio de la línea de comandos de Java a tu punto de entrada de imagen de contenedor para que tu aplicación se inicie con estos argumentos:

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

El uso de memoria nativa se puede estimar en función de la cantidad de clases que se cargarán. Considera usar una calculadora de memoria de Java de código abierto para estimar las necesidades de memoria.

Desactiva el compilador de optimización

De forma predeterminada, la JVM tiene varias fases de compilación de JIT. Aunque estas fases mejoran la eficiencia de tu aplicación a lo largo del tiempo, también pueden agregar sobrecarga al uso de memoria y aumentar el tiempo de inicio.

En el caso de las aplicaciones sin servidores de corta duración (por ejemplo, las funciones), considera desactivar las fases de optimización para intercambiar eficiencia a largo plazo por un tiempo de inicio reducido.

Para un servicio de Cloud Run, configura la variable de entorno:

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

Usa datos compartido de clase de la aplicación

Para reducir aún más el uso de tiempo y de memoria de JIT, te recomendamos aprovechar el uso compartido de datos de clase de la aplicación (AppCDS) a fin de compartir las clases de Java compiladas con anticipación como un archivo. El archivo AppCDS se puede volver a usar cuando se inicia otra instancia de la misma aplicación de Java. La JVM puede volver a usar los datos procesados con anterioridad del archivo, lo que reduce el tiempo de inicio.

Las siguientes consideraciones se aplican al uso de AppCDS:

  • El archivo AppCDS que se volverá a usar debe reproducirse exactamente con la misma distribución, versión y arquitectura de OpenJDK que se usó en un principio para producirlo.
  • Debes ejecutar la aplicación al menos una vez para generar la lista de clases que se compartirán y, luego, usar esa lista para generar el archivo AppCDS.
  • La cobertura de las clases depende de la ruta de código que se use durante la ejecución de la aplicación. Para aumentar la cobertura, activa de manera programática más rutas de código.
  • La aplicación debe salir de forma correcta para generar esta lista de clases. Te recomendamos implementar una marca de aplicación que se use para indicar la generación del archivo AppCDS a fin de que pueda salir de inmediato.
  • El archivo AppCDS solo se puede volver a usar si inicias instancias nuevas de la misma manera en que se generó el archivo.
  • El archivo AppCDS solo funciona con un paquete de archivo JAR normal. No puedes usar archivos JAR anidados.

Ejemplo de Spring Boot con un archivo JAR sombreado

Las aplicaciones de Spring Boot usan un uber JAR anidado de forma predeterminada, que no funcionará con AppCDS. Por lo tanto, si usas AppCDS, debes crear un JAR sombreado. Por ejemplo, puedes usar Maven y el complemento 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>

Si el archivo JAR sombreado contiene todas las dependencias, puedes producir un archivo simple durante la compilación del contenedor mediante un archivo 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

Reduce el tamaño de la pila de subprocesos

La mayoría de las aplicaciones web de Java se basan en subprocesos por conexión. Cada subproceso de Java consume memoria nativa (no en el montón). Esto se conoce como pila de subprocesos, y se configura de manera predeterminada en 1 MB por subproceso. Si tu aplicación maneja 80 solicitudes simultáneas, puede tener al menos 80 subprocesos, lo que se equivale al uso de 80 MB de espacio de pila de subprocesos. La memoria se suma al tamaño del montón. El valor predeterminado puede ser mayor que el necesario. Puedes reducir el tamaño de la pila de subprocesos.

Si reduces demasiado, verás java.lang.StackOverflowError. Puedes generar perfiles de tu aplicación y encontrar el tamaño de pila de subprocesos óptimo para configurar.

Para un servicio de Cloud Run, configura la variable de entorno:

JAVA_TOOL_OPTIONS="-Xss256k"

Reduce subprocesos

Para optimizar la memoria, puedes reducir la cantidad de subprocesos mediante el uso de estrategias de reactivación sin bloqueo y al evitar actividades en segundo plano.

Reduce la cantidad de subprocesos

Cada subproceso de Java puede aumentar el uso de memoria debido a la pila de subprocesos. Cloud Run permite un máximo de 1000 solicitudes simultáneas. Con el modelo de subproceso por conexión, necesitas un máximo de 1000 subprocesos para manejar todas las solicitudes simultáneas. La mayoría de los servidores web y frameworks te permiten configurar la cantidad máxima de subprocesos y conexiones. Por ejemplo, en Spring Boot, puedes limitar las conexiones máximas en el archivo applications.properties:

server.tomcat.max-threads=80

Escribe un código reactivo sin bloqueo para optimizar la memoria y el inicio

Para reducir realmente la cantidad de subprocesos, considera adoptar un modelo de programación reactivo sin bloqueo, de modo que la cantidad de subprocesos se pueda reducir de manera significativa mientras se manejan más solicitudes simultáneas. Los frameworks de aplicaciones, como Spring Boot con Webflor, Micronaut y Quarkus, son compatibles con las aplicaciones web reactivas.

Los frameworks reactivos, como Spring Boot con Webflor, Micronaut y Quarkus, suelen tener tiempos de inicio más rápidos.

Si continúas escribiendo código de bloqueo en un framework sin bloqueo, la capacidad de procesamiento y las tasas de error serán mucho peores en un servicio de Cloud Run. Esto se debe a que los frameworks sin bloqueo solo tendrán algunos subprocesos, por ejemplo, 2 o 4. Si el código se bloquea, puede manejar muy pocas solicitudes simultáneas.

Estos frameworks sin bloqueo también pueden descargar el código de bloqueo en un grupo de subprocesos no delimitado, lo que significa que, si bien puede aceptar muchas solicitudes simultáneas, el código de bloqueo se ejecutará en subprocesos nuevos. Si los subprocesos se acumulan de forma ilimitada, agotarás el recurso de la CPU y comenzará a fallar. La latencia se verá muy afectada. Si usas un framework sin bloqueo, asegúrate de comprender los modelos del conjunto de subprocesos y vincular los grupos según corresponda.

Configura la CPU para que se asigne siempre si utilizas actividades en segundo plano

La actividad en segundo plano es todo lo que sucede después de que se entrega la respuesta HTTP. Las cargas de trabajo tradicionales que ejecutan tareas en segundo plano necesitan una consideración especial cuando se ejecutan en Cloud Run.

Configura la CPU para que se asigne siempre

Si deseas admitir actividades en segundo plano en tu servicio de Cloud Run, configura tu CPU de servicio de Cloud Run para que siempre se asigne a fin de que puedas ejecutar actividades en segundo plano fuera de las solicitudes. tienen acceso a la CPU

Evita las actividades en segundo plano si la CPU se asigna solo durante el procesamiento de la solicitud

Si necesitas configurar tu servicio para asignar CPU solo durante el procesamiento de solicitudes, debes estar al tanto de los posibles problemas con las actividades en segundo plano. Por ejemplo, si recopilas métricas de aplicaciones y agrupas las métricas en segundo plano en lotes para enviarlas de forma periódica, esas métricas no se enviarán cuando se limite la CPU. Si tu aplicación recibe solicitudes de manera constante, es posible que tengas menos problemas. Si tu aplicación tiene un QPS baja, es posible que la tarea en segundo plano nunca se ejecute.

Algunos patrones conocidos que se ejecutan en segundo plano a los que debes prestar atención si decides asignar CPU solo durante el procesamiento de solicitudes:

  • Grupos de conexiones de JDBC: Las limpiezas y las verificaciones de conexión suelen realizarse en segundo plano.
  • Remitentes de seguimiento distribuidos: Por lo general, los seguimientos distribuidos se agrupan en lotes y se envían de forma periódica o cuando el búfer se llena en segundo plano.
  • Remitentes de métricas: Las métricas se suelen agrupar en lotes y enviar de forma periódica en segundo plano.
  • En el caso Spring Boot, cualquier método anotado con la anotación @Async.
  • Temporizadores: Cualquier activador basado en el temporizador (p.ej., ScheduledThreadPoolExecutor, Quartz, o la anotación @Scheduled de Spring) puede que no se ejecuten si las CPU no están asignadas.
  • Receptores de mensajes: Por ejemplo, los clientes de extracción de transmisión de Pub/Sub, los clientes JMS o los clientes de Kafka, por lo general, se ejecutan en subprocesos en segundo plano sin necesidad de enviar solicitudes. Estos no funcionarán si tu aplicación no tiene solicitudes. No se recomienda recibir mensajes de esta manera en Cloud Run.

Optimizaciones de aplicaciones

En el código de servicio de Cloud Run, también puedes optimizar el tiempo de inicio y el uso de memoria más rápidos.

Reduce las tareas de inicio

Las aplicaciones tradicionales basadas en la Web de Java pueden tener muchas tareas para completar durante el inicio, p. ej., la precarga de datos, la preparación de la caché, el establecimiento de grupos de conexión, etc. Cuando estas tareas se ejecutan de forma secuencial, pueden ser lentas. Sin embargo, si deseas que se ejecuten en paralelo, debes aumentar la cantidad de núcleos de CPU.

Por el momento, Cloud Run envía una solicitud de usuario real para activar una instancia de inicio en frío. Los usuarios que tienen una solicitud asignada a una instancia recién iniciada pueden experimentar demoras prolongadas. En la actualidad, Cloud Run no tiene una verificación de “preparación” para evitar enviar solicitudes a aplicaciones que no están listas.

Usa las agrupaciones de conexiones

Si usas grupos de conexiones, ten en cuenta que pueden expulsar conexiones innecesarias en segundo plano (consulta Evita tareas en segundo plano). Si tu aplicación tiene una QPS baja y puede tolerar una latencia alta, considera abrir y cerrar conexiones por solicitud. Si tu aplicación tiene una QPS alta, las expulsiones en segundo plano pueden seguir ejecutándose mientras haya solicitudes activas.

En ambos casos, el acceso a la base de datos de la aplicación se aplicará a un nivel de interrupción de la cantidad máxima de conexiones que permite la base de datos. Calcula las conexiones máximas que puedes establecer por instancia de Cloud Run y configura las instancias máximas de Cloud Run a fin de que la cantidad máxima de instancias por conexión sea menor que la cantidad máxima permitida.

Si usas Spring Boot

Si usas Spring Boot, debes considerar las siguientes optimizaciones

Usa Spring Boot versión 2.2 o posterior

A partir de la versión 2.2, Spring Boot se optimizó en gran medida para brindar velocidad en el inicio. Si usas versiones anteriores a Spring Boot 2.2, considera actualizar o aplicar optimizaciones individuales de forma manual.

Usa la inicialización diferida

Existe una marca de inicialización diferida global que se puede activar en Spring Boot 2.2 y en versiones posteriores. Esto mejorará la velocidad de inicio, pero con la compensación de que la primera solicitud pueda tener una latencia más larga, ya que deberá esperar a que los componentes se inicialicen por primera vez.

Puedes activar la inicialización diferida en application.properties:

spring.main.lazy-initialization=true

O bien, mediante una variable de entorno:

SPRING_MAIN_LAZY_INITIALIZATIION=true

Sin embargo, si usas instancias mínimas, la inicialización diferida no será de ayuda, ya que la inicialización debía ocurrir cuando se inició la instancia mínima.

Evita el análisis de clases

El análisis de clases generará lecturas de disco adicionales en Cloud Run porque el acceso al disco suele ser más lento que una máquina normal. Asegúrate de que el análisis de componentes esté limitado o se evite por completo. Considera usar Spring Context Indexer para generar un índice con anterioridad. La mejora de la velocidad de inicio variará según la aplicación.

Por ejemplo, en tu pom.xml de Maven, agrega la dependencia del indexador (en realidad, es un procesador de anotaciones):

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

Usa herramientas para desarrolladores de Spring Boot que no estén en producción

Si usas la herramienta para desarrolladores de Spring Boot durante el desarrollo, asegúrate de que no esté empaquetada en la imagen de contenedor de producción. Esto puede ocurrir si compilaste la aplicación de Spring Boot sin los complementos de compilación de Spring Boot (por ejemplo, si usaste el complemento Shade o el complemento Jib para organizar en contenedores).

En estos casos, asegúrate de que la herramienta de compilación excluya la herramienta para desarrolladores de Spring de forma explícita. También puedes desactivar la herramienta para desarrolladores de Spring Boot de forma explícita.

¿Qué sigue?

Para obtener más sugerencias, consulta estos artículos: