En esta guía se describen las optimizaciones de los servicios de Cloud Run escritos en el lenguaje de programación Java, así como información general para ayudarte a entender las ventajas y desventajas de algunas de las optimizaciones. La información de esta página complementa los consejos de optimización generales, que también se aplican a Java.
Las aplicaciones web Java tradicionales están diseñadas para atender solicitudes con alta simultaneidad y baja latencia, y suelen ser aplicaciones de larga duración. La propia JVM también optimiza el código de ejecución a lo largo del tiempo con JIT, de modo que las rutas activas se optimizan y las aplicaciones se ejecutan de forma más eficiente con el tiempo.
Muchas de las prácticas recomendadas y optimizaciones de estas aplicaciones web tradicionales de Java se centran en lo siguiente:
- Gestionar solicitudes simultáneas (tanto basadas en hilos como de E/S no bloqueantes)
- Reducir la latencia de respuesta mediante la agrupación de conexiones y el procesamiento por lotes de funciones no críticas, como el envío de trazas y métricas a tareas en segundo plano.
Aunque muchas de estas optimizaciones tradicionales funcionan bien en aplicaciones de larga duración, puede que no funcionen tan bien en un servicio de Cloud Run, que solo se ejecuta cuando atiende solicitudes de forma activa. En esta página se describen algunas optimizaciones y compensaciones de Cloud Run que puedes usar para reducir el tiempo de inicio y el uso de memoria.
Usar el aumento de CPU al inicio para reducir la latencia de inicio
Puedes habilitar el aumento de la CPU al inicio para aumentar temporalmente la asignación de CPU durante el inicio de la instancia y, de este modo, reducir la latencia de inicio.
Las métricas de Google han demostrado que las aplicaciones Java se benefician si usan el aumento de CPU al inicio, que puede reducir los tiempos de inicio hasta en un 50%.
Optimizar la imagen de contenedor
Si optimizas la imagen del contenedor, puedes reducir los tiempos de carga e inicio. Puedes optimizar la imagen de las siguientes formas:
- Minimizar la imagen de contenedor
- Evitar el uso de archivos JAR de bibliotecas anidadas
- Usar Jib
Minimizar la imagen de contenedor
Consulta la página de consejos generales sobre cómo minimizar el contenedor para obtener más contexto sobre este problema. En la página de consejos generales se recomienda reducir el contenido de las imágenes de los contenedores a lo que sea necesario. Por ejemplo, asegúrate de que tu imagen de contenedor no contenga lo siguiente :
- Código fuente
- Artefactos de compilación de Maven
- Herramientas de compilación
- Directorios de Git
- Binarios o utilidades sin usar
Si vas a compilar el código desde un Dockerfile, usa la compilación de varias fases de Docker para que la imagen de contenedor final solo tenga el JRE y el archivo JAR de la aplicación.
Evita los archivos JAR de bibliotecas anidadas
Algunos frameworks populares, como Spring Boot, crean un archivo de aplicación (JAR) que contiene archivos JAR de biblioteca adicionales (JARs anidados). Estos archivos deben descomprimirse durante el tiempo de inicio, lo que puede afectar negativamente a la velocidad de inicio en Cloud Run. Por lo tanto, cuando sea posible, crea un archivo JAR ligero con bibliotecas externalizadas. Puedes automatizar este proceso usando Jib para crear contenedores de tu aplicación.
Usar Jib
Usa el plugin Jib para crear un contenedor mínimo y acoplar el archivo de la aplicación automáticamente. Jib funciona con Maven y Gradle, y también con aplicaciones Spring Boot. Algunos frameworks de aplicaciones pueden requerir configuraciones adicionales de Jib.
Optimizaciones de JVM
Optimizar la JVM de un servicio de Cloud Run puede mejorar el rendimiento y el uso de memoria.
Usar versiones de JVM compatibles con contenedores
En las VMs y las máquinas, en el caso de las asignaciones de CPU y memoria, la JVM conoce la CPU y la memoria que puede usar a partir de ubicaciones conocidas, como /proc/cpuinfo
y /proc/meminfo
en Linux. Sin embargo, cuando se ejecuta en un contenedor, las restricciones de CPU y memoria se almacenan en /proc/cgroups/...
. Las versiones anteriores del JDK siguen buscando en /proc
en lugar de en /proc/cgroups
, lo que puede provocar que se use más CPU y memoria de la que se ha asignado. Esto puede provocar lo siguiente:
- Un número excesivo de subprocesos porque el tamaño del grupo de subprocesos se configura mediante
Runtime.availableProcessors()
- Un montón máximo predeterminado que supera el límite de memoria del contenedor. La JVM usa la memoria de forma agresiva antes de recoger los elementos no utilizados. Esto puede provocar fácilmente que el contenedor supere el límite de memoria y que se cierre por falta de memoria.
Por lo tanto, usa una versión de JVM compatible con contenedores. Las versiones de OpenJDK iguales o posteriores a la versión 8u192
son compatibles con contenedores de forma predeterminada.
Cómo entender el uso de la memoria de JVM
El uso de memoria de la JVM se compone del uso de memoria nativa y del uso del montículo. La memoria de trabajo de tu aplicación suele estar en el montículo. El tamaño del montículo está limitado por la configuración de montículo máximo. Con una instancia de Cloud Run de 256 MB de RAM, no puedes asignar los 256 MB a Max Heap, 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 se cierra por falta de memoria y necesitas saber el uso de memoria de la JVM (memoria nativa + montículo), activa el seguimiento de memoria nativa para ver los usos cuando la aplicación se cierre correctamente. Si tu aplicación se cierra por falta de memoria, no podrá imprimir la información. En ese caso, ejecuta la aplicación con más memoria primero para que pueda generar el resultado correctamente.
No se puede activar el seguimiento de memoria nativa mediante la variable de entorno JAVA_TOOL_OPTIONS
. Debes añadir el argumento de inicio de línea de comandos de Java al punto de entrada de la imagen de tu 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 del número de clases que se vayan a cargar. Puedes usar una calculadora de memoria de Java de código abierto para estimar las necesidades de memoria.
Desactivar el compilador de optimización
De forma predeterminada, la JVM tiene varias fases de compilación JIT. Aunque estas fases mejoran la eficiencia de tu aplicación con el tiempo, también pueden añadir sobrecarga al uso de memoria y aumentar el tiempo de inicio.
En el caso de las aplicaciones sin servidor de corta duración (por ejemplo, las funciones), te recomendamos que desactives las fases de optimización para cambiar la eficiencia a largo plazo por un tiempo de inicio más corto.
En el caso de un servicio de Cloud Run, configura la variable de entorno:
JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"
Usar el uso compartido de datos de clase de aplicación
Para reducir aún más el tiempo de JIT y el uso de memoria, puedes usar compartición de datos de clase de aplicación (AppCDS) para compartir las clases de Java compiladas con antelación como archivo. El archivo AppCDS se puede reutilizar al iniciar otra instancia de la misma aplicación Java. La JVM puede reutilizar los datos precalculados del archivo, lo que reduce el tiempo de inicio.
Al usar AppCDS, debes tener en cuenta lo siguiente:
- El archivo AppCDS que se va a reutilizar debe reproducirse con exactamente la misma distribución, versión y arquitectura de OpenJDK que se usó originalmente para producirlo.
- Debes ejecutar tu aplicación al menos una vez para generar la lista de clases que se van a compartir y, a continuación, usar esa lista para generar el archivo AppCDS.
- La cobertura de las clases depende de la ruta de código ejecutada durante la ejecución de la aplicación. Para aumentar la cobertura, activa de forma programática más rutas de código.
- La aplicación debe cerrarse correctamente para generar esta lista de clases. Considera la posibilidad de implementar una marca de aplicación que se use para indicar la generación del archivo AppCDS y, de este modo, pueda salir inmediatamente.
- El archivo AppCDS solo se puede reutilizar si inicias nuevas instancias exactamente de la misma forma en que se generó el archivo.
- El archivo AppCDS solo funciona con un paquete de archivos JAR normal. No puedes usar archivos JAR anidados.
Ejemplo de Spring Boot con un archivo JAR sombreado
Las aplicaciones Spring Boot usan un archivo JAR uber anidado de forma predeterminada, que no funciona con AppCDS. Por lo tanto, si usas AppCDS, debes crear un JAR sombreado. Por ejemplo, si usas 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 tu JAR sombreado contiene todas las dependencias, puedes generar un archivo sencillo durante la compilación del contenedor mediante un 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 Java se basan en un hilo por conexión. Cada hilo de Java consume memoria nativa (no en el montículo). Esto se conoce como pila de subprocesos y tiene un valor predeterminado de 1 MB por subproceso. Si tu aplicación gestiona 80 solicitudes simultáneas, puede que tenga al menos 80 hilos, lo que se traduce en 80 MB de espacio de pila de hilos utilizado. La memoria se suma al tamaño del montículo. El valor predeterminado puede ser mayor de lo necesario. Puedes reducir el tamaño de la pila de subprocesos.
Si la reduces demasiado, verás java.lang.StackOverflowError
. Puedes crear un perfil de tu aplicación y encontrar el tamaño de pila de subprocesos óptimo para configurarlo.
En el caso de un servicio de Cloud Run, configura la variable de entorno:
JAVA_TOOL_OPTIONS="-Xss256k"
Reducir las conversaciones
Puedes optimizar la memoria reduciendo el número de subprocesos, usando estrategias reactivas no bloqueantes y evitando las actividades en segundo plano.
Reducir el número de cadenas
Cada hilo 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 un hilo por conexión, necesitas un máximo de 1000 hilos para gestionar todas las solicitudes simultáneas.
La mayoría de los servidores y frameworks web te permiten configurar el número máximo de hilos y conexiones. Por ejemplo, en Spring Boot, puedes limitar el número máximo de conexiones en el archivo applications.properties
:
server.tomcat.max-threads=80
Escribir código reactivo no bloqueante para optimizar la memoria y el inicio
Para reducir realmente el número de hilos, considera la posibilidad de adoptar un modelo de programación reactivo no bloqueante, de forma que el número de hilos se pueda reducir significativamente al tiempo que se gestionan más solicitudes simultáneas. Los frameworks de aplicaciones, como Spring Boot con Webflux, Micronaut y Quarkus, admiten aplicaciones web reactivas.
Los frameworks reactivos, como Spring Boot con Webflux, Micronaut y Quarkus, suelen tener tiempos de inicio más rápidos.
Si sigues escribiendo código de bloqueo en un framework sin bloqueo, el rendimiento y las tasas de errores serán significativamente peores en un servicio de Cloud Run. Esto se debe a que los frameworks no bloqueadores solo tendrán unos pocos hilos, por ejemplo, 2 o 4. Si tu código es de bloqueo, solo podrá gestionar muy pocas solicitudes simultáneas.
Estos frameworks no bloqueantes también pueden descargar código bloqueante en un grupo de subprocesos ilimitado, lo que significa que, aunque pueden aceptar muchas solicitudes simultáneas, el código bloqueante se ejecutará en nuevos subprocesos. Si los hilos se acumulan de forma ilimitada, agotarás el recurso de CPU y empezarás a thrash. La latencia se verá gravemente afectada. Si usas un framework no bloqueante, asegúrate de entender los modelos de grupo de subprocesos y de limitar los grupos en consecuencia.
Configurar la facturación basada en instancias si usas actividades en segundo plano
La actividad en segundo plano es cualquier acción que se produce después de que se haya enviado la respuesta HTTP. Las cargas de trabajo tradicionales que tienen tareas en segundo plano requieren una consideración especial cuando se ejecutan en Cloud Run.
Configurar la facturación basada en instancias
Si quieres admitir actividades en segundo plano en tu servicio de Cloud Run, configura tu servicio de Cloud Run para que use la facturación basada en instancias. De esta forma, podrás ejecutar actividades en segundo plano fuera de las solicitudes y seguir teniendo acceso a la CPU.
Evita las actividades en segundo plano si usas la facturación basada en solicitudes
Si necesitas configurar tu servicio para que use la facturación basada en solicitudes, debes tener en cuenta los posibles problemas con las actividades en segundo plano. Por ejemplo, si recoges métricas de aplicaciones y las agrupas en lote en segundo plano para enviarlas periódicamente, esas métricas no se enviarán cuando se configure la facturación basada en solicitudes. Si tu aplicación recibe solicitudes constantemente, es posible que tengas menos problemas. Si tu aplicación tiene un QPS bajo, es posible que la tarea en segundo plano nunca se ejecute.
Estos son algunos patrones conocidos que se ejecutan en segundo plano y a los que debes prestar atención si eliges la facturación basada en solicitudes:
- Grupos de conexiones JDBC: las limpiezas y las comprobaciones de conexiones suelen realizarse en segundo plano.
- Remitentes de seguimiento distribuido: los seguimientos distribuidos suelen agruparse y enviarse periódicamente o cuando el búfer está lleno en segundo plano.
- Remitentes de métricas: las métricas suelen agruparse y enviarse periódicamente en segundo plano.
- En Spring Boot, cualquier método anotado con la anotación
@Async
- Temporizadores: cualquier activador basado en temporizadores (por ejemplo, ScheduledThreadPoolExecutor,
Quartz o la anotación
@Scheduled
de Spring) puede que no se ejecuten cuando se configura la facturación basada en solicitudes. - Receptores de mensajes: por ejemplo, los clientes de extracción de streaming de Pub/Sub, los clientes de JMS o los clientes de Kafka suelen ejecutarse en los subprocesos en segundo plano sin necesidad de solicitudes. No funcionarán si tu aplicación no tiene solicitudes. No se recomienda recibir mensajes de esta forma en Cloud Run.
Optimizaciones de aplicaciones
En el código de tu servicio de Cloud Run, también puedes optimizar los tiempos de inicio y el uso de memoria.
Reducir las tareas de inicio
Las aplicaciones web tradicionales de Java pueden tener muchas tareas que completar durante el inicio, como la precarga de datos, el calentamiento de la caché, el establecimiento de grupos de conexiones, etc. Estas tareas, cuando se ejecutan secuencialmente, pueden ser lentas. Sin embargo, si quieres que se ejecuten en paralelo, debes aumentar el número de núcleos de CPU.
Actualmente, Cloud Run envía una solicitud de usuario real para activar una instancia de arranque en frío. Los usuarios a los que se les haya asignado una solicitud a una instancia recién iniciada pueden experimentar retrasos prolongados. Actualmente, Cloud Run no tiene una comprobación de disponibilidad para evitar enviar solicitudes a aplicaciones que no estén listas.
Usar grupos de conexiones
Si usas grupos de conexiones, ten en cuenta que estos pueden expulsar conexiones innecesarias en segundo plano (consulta la sección Evitar tareas en segundo plano). Si tu aplicación tiene un valor de consultas por segundo bajo y puede tolerar una latencia alta, te recomendamos que abras y cierres las conexiones por solicitud. Si tu aplicación tiene un QPS alto, 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 verá limitado por el número máximo de conexiones permitidas por la base de datos. Calcula el número máximo de conexiones que puedes establecer por instancia de Cloud Run y configura el número máximo de instancias de Cloud Run para que el número máximo de instancias multiplicado por el número de conexiones por instancia sea inferior al número máximo de conexiones permitidas.
Si usas Spring Boot
Si usas Spring Boot, debes tener en cuenta las siguientes optimizaciones:
Usar Spring Boot 2.2 o una versión posterior
A partir de la versión 2.2, Spring Boot se ha optimizado considerablemente para mejorar la velocidad de inicio. Si usas versiones de Spring Boot anteriores a la 2.2, te recomendamos que actualices o apliques optimizaciones individuales manualmente.
Usar la inicialización en diferido
Hay una marca de inicialización diferida global que se puede activar en Spring Boot 2.2 y versiones posteriores. De esta forma, se mejorará la velocidad de inicio, pero la primera solicitud puede tener una latencia mayor porque tendrá que esperar a que los componentes se inicialicen por primera vez.
Puedes activar la inicialización en diferido en application.properties
:
spring.main.lazy-initialization=true
También puedes usar una variable de entorno:
SPRING_MAIN_LAZY_INITIALIZATIION=true
Sin embargo, si usas min-instances, la inicialización diferida no te servirá de ayuda, ya que la inicialización debería haberse producido cuando se inició la min-instance.
Evitar el análisis de clases
El análisis de clases provocará lecturas de disco adicionales en Cloud Run porque, en Cloud Run, el acceso al disco suele ser más lento que en una máquina normal. Asegúrate de que el análisis de componentes sea limitado o se evite por completo.
Usar las herramientas para desarrolladores de Spring Boot en entornos que no sean de producción
Si usas Spring Boot Developer Tool durante el desarrollo, asegúrate de que no esté empaquetada en la imagen del contenedor de producción. Esto puede ocurrir si has creado la aplicación Spring Boot sin los complementos de compilación de Spring Boot (por ejemplo, si has usado el complemento Shade o Jib para crear un contenedor).
En estos casos, asegúrate de que la herramienta de compilación excluya explícitamente la herramienta de desarrollo de Spring Boot. También puedes desactivar explícitamente la herramienta para desarrolladores de Spring Boot.
Siguientes pasos
Para ver más consejos, consulta