En esta guía se describen las optimizaciones de los servicios de Knative Serving escritos en el lenguaje de programación Java, así como información general para ayudarte a comprender 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, es posible que no funcionen tan bien en un servicio de Knative, que solo se ejecuta cuando sirve solicitudes de forma activa. En esta página se describen algunas optimizaciones y compensaciones diferentes para el servicio de Knative que puedes usar para reducir el tiempo de inicio y el uso de memoria.
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 estrictamente 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.
Evitar archivos JAR de bibliotecas anidados
Algunos frameworks populares, como Spring Boot, crean un archivo JAR de aplicación que contiene archivos JAR de biblioteca adicionales (JARs anidados). Estos archivos deben descomprimirse durante el tiempo de inicio y pueden aumentar la velocidad de inicio en Knative Serving. Cuando sea posible, crea un archivo JAR ligero con bibliotecas externalizadas: puedes automatizar este proceso usando Jib para contenerizar 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 para un servicio de Knative Serving 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 del contenedor 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.
Información sobre el uso de memoria de la 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 Knative serving 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 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 Knative Serving, configure 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 adoptopenjdk:11-jre-hotspot 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 adoptopenjdk:11-jre-hotspot
# 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
Desactivar la verificación de clase
Cuando la JVM carga clases en la memoria para ejecutarlas, verifica que la clase no se haya manipulado y que no tenga ediciones maliciosas ni esté dañada. Si tu canalización de entrega de software es de confianza (por ejemplo, puedes verificar y validar cada salida), si puedes confiar plenamente en el bytecode de tu imagen de contenedor y tu aplicación no carga clases de fuentes remotas arbitrarias, puedes desactivar la verificación. Desactivar la verificación puede mejorar la velocidad de inicio si se carga un gran número de clases en el momento del inicio.
En el caso de un servicio de Knative Serving, configure la variable de entorno:
JAVA_TOOL_OPTIONS="-noverify"
Reducir 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 lo 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 Knative Serving, configure 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 hilos.
Knative Serving permite un máximo de 80 solicitudes simultáneas. Con el modelo de un hilo por conexión, necesitas un máximo de 80 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 Knative Serving. 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.
Evitar actividades en segundo plano
Knative Serving limita la CPU de una instancia cuando esta instancia deja de recibir solicitudes. Las cargas de trabajo tradicionales que tienen tareas en segundo plano requieren una consideración especial cuando se ejecutan en Knative Serving.
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 limite la CPU. Si tu aplicación recibe solicitudes constantemente, es posible que veas 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:
- Grupos de conexiones JDBC: las limpiezas y las comprobaciones de conexiones suelen realizarse en segundo plano.
- Remitentes de trazas distribuidas: las trazas distribuidas 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
@Scheduled
anotación de Spring) puede que no se ejecuten cuando se limite la velocidad de las CPUs. - 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 Knative Serving.
Optimizaciones de aplicaciones
En el código de servicio de Knative Serving, 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, Knative Serving 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, Knative Serving no tiene una comprobación de "preparación" para evitar enviar solicitudes a aplicaciones que no estén preparadas.
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, plantéate abrir y cerrar 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 Knative Serving y configura el número máximo de instancias de Knative Serving de forma 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.
Usar Spring Boot
Si usas Spring Boot, debes tener en cuenta las siguientes optimizaciones:
Usar la versión 2.2 o una posterior de Spring Boot
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 diferida
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 Knative Serving porque, en Knative Serving, 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. Te recomendamos que uses Spring Context Indexer para pregenerar un índice. Si esto mejora la velocidad de inicio dependerá de tu aplicación.
Por ejemplo, en tu Maven pom.xml
, añade 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>
Usar las herramientas para desarrolladores de Spring Boot en un entorno que no sea de producción
Si usas la herramienta de desarrollo de Spring Boot 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