Hoy en día es bastante común encontrar aplicaciones de todo tipo desplegadas con Docker, una tecnología que ofrece numerosas ventajas entre las cuales podemos encontrar una gran flexibilidad para obtener un consumo de recursos reducido.
Un contenedor Docker bien dimensionado permite que la aplicación que aloja pueda dar servicio de manera óptima a la vez que no suponga un desperdicio de recursos debido a un sobredimensionamiento innecesario.
Hace un tiempo nos encontrábamos optimizando el consumo de memoria de una aplicación Java/Spring Boot en un contenedor Docker. Si quieres saber cómo lo hicimos, ¡continúa leyendo!
Contenedor Docker de partida
Podemos desplegar contenedores que ejecuten aplicaciones Java partiendo de una imagen que contenga una implementación de la Java Platform. Nosotros escogimos una imagen de OpenJDK, una implementación open-source de la Java Platform (Standard Edition). Concretamente, elegimos 8-jdk-alpine, ya que tiene un tamaño muy reducido al estar basada en Alpine Linux.
Una vez escogida la imagen base, creamos un Dockerfile que nos permitiría construir la imagen final con nuestra aplicación Spring Boot incluida.
FROM openjdk:8-jdk-alpine
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
ARG JAR_FILE=/build/*.jar
ADD --chown=spring:spring ${JAR_FILE} app.jar
ENV JAVA_OPTS=''
ENTRYPOINT java $JAVA_OPTS -jar /app.jar
Como podemos ver, el Dockerfile es bastante simple. Únicamente aplicamos unos pocos cambios sobre la imagen base para poder incluir el fichero .jar correspondiente a nuestra aplicación Spring Boot y establecer la ejecución del mismo como punto de entrada al levantar un contenedor.
Además, mediante la variable de entorno JAVA_OPTS permitimos que, en el momento de crear un contenedor a partir de la imagen, sea posible añadir de forma opcional diferentes flags sobre la Java Virtual Machine (JVM) para limitar el consumo de recursos o habilitar ciertas funciones.
A partir de la imagen anterior, se puede desplegar un contenedor de diferentes formas. Para este ejemplo, vamos a plantear la creación de nuestro contenedor a través de Docker Compose:
version: '2.2'
services:
springboot_app:
container_name: 'springboot_app'
image: 'springboot_app:1.0'
ports:
- '8080:8080'
restart: always
Por defecto, y al no establecer ninguna restricción al respecto, Docker desplegará el contenedor permitiendo a este que ocupe tanta memoria como el planificador del Kernel del host permita. En sistemas Linux, si el Kernel detecta que no hay suficiente memoria para llevar a cabo tareas importantes, se producirá un OOME (Out Of Memory Exception) y comenzará a matar procesos para liberar espacio.
Establecer un límite en el consumo de memoria de los contenedores nos ayudará a tener un mayor control sobre la memoria y, al mismo tiempo, evitar sobrecostes de infraestructura.
Podemos obtener el límite de memoria asignado a los contenedores en ejecución a través del comando docker stats:
$ docker stats --format "table {{.Name}}\t{{.MemUsage}}"
NAME MEM USAGE / LIMIT
springboot_app 246.1MiB / 4.833GiB
Para poder ejecutar Docker sobre mi MacBook Pro uso Docker Desktop. Como no hemos establecido ninguna restricción, la memoria límite coincide con el máximo de memoria disponible para Docker Desktop, en mi caso 5Gb.
Ahora bien, ¿cómo sabemos cuánta memoria necesita realmente el contenedor?
Nuestro contenedor ejecuta un proceso Java, por lo que una primera aproximación es comprender cómo se gestiona el consumo de memoria en este tipo de procesos.
Entendiendo la memoria de un proceso Java
La memoria total que ocupa un proceso Java se puede dividir en dos bloques principales: Heap Memory y Non-Heap memory.
Heap Memory
La Heap Memory está destinada a objetos Java. Esta memoria se inicializa en el arranque de la JVM y su tamaño puede variar durante la ejecución de la aplicación.
Cuando esta memoria se llena, el Garbage Collector (GC) realiza una limpieza de los objetos en desuso, dejando espacio disponible para nuevos.
Si no establecemos un límite para la Heap Memory, este será establecido de forma automática. Esta asignación de memoria automática se lleva a cabo teniendo en cuenta la memoria física disponible del sistema, entre otros aspectos de configuración, además de la versión concreta de la Java Platform. Este límite se puede establecer con el flag -Xmx.
Podemos consultar cuál es el límite de Heap Memory establecido en nuestro contenedor. Lo primero que debemos hacer es abrir una sesión shell dentro del contenedor:
docker exec -it springboot_app /bin/sh
A continuación ejecutamos el siguiente comando, el cual nos devuelve el total en bytes correspondiente al límite de memoria del contenedor destinada a Heap Memory:
$ java -XX:+PrintFlagsFinal -version | grep -iE 'MaxHeapSize'
uintx MaxHeapSize := 1298137088 {product}
El comando java -XX:+PrintFlagsFinal -version nos permite obtener por pantalla el valor de los diferentes flags de la JVM. En nuestro caso usamos grep para filtrar el flag concreto que buscamos.
El límite de Heap Memory establecido en nuestro contenedor es de aproximadamente 1.3GB.
Non-Heap Memory
La memoria que consume un proceso Java no se reduce únicamente a la Heap Memory. La JVM incluye varios componentes necesarios que suponen un consumo de memoria añadido. La memoria destinada a estos componentes es independiente de la Heap Memory y se conoce como Non-Heap memory.
Dentro de la Non-Heap Memory encontramos: Garbage Collector, Class Metaspace, JIT compiler, Code Cache, threads, etc. Existen flags para limitar el consumo de memoria de algunos de estos componentes, pero no todos disponen de esta opción.
Al haber numerosos factores a tener en cuenta y algunos fuera de nuestro alcance, no existe una forma de calcular el tamaño total que ocupa la Non-Heap Memory de forma directa. De todas formas, existen herramientas que nos pueden ayudar a monitorizar este bloque de memoria.
Monitorizando nuestro proceso Java sin optimizar
Para optimizar el consumo de memoria de nuestro proceso Java, lo primero que hicimos fue monitorizar el proceso en “circunstancias normales”. Es decir, sin aplicar ninguna limitación de memoria sobre el mismo. De esta manera pudimos visualizar hasta cuánto ascendía el total de Heap Memory consumido en tiempo de ejecución, así como el consumo de otros componentes de la Non-Heap Memory.
Nuestro objetivo era que, a partir de esta información, pudiésemos dimensionar la memoria destinada al proceso acorde a las necesidades reales.
Existen diferentes maneras de monitorizar un proceso Java. Desde utilidades para la línea de comandos como jcmd hasta herramientas visuales como JConsole. En este ejemplo usaremos JConsole para poder presentar gráficos.
Para poder usar JConsole primero tenemos que activar las Java Management Extensions (JMX). Para ello es necesario añadir ciertas opciones a las JAVA_OPTS dentro de la definición de nuestro contenedor:
version: '2.2'
services:
springboot_app:
container_name: 'springboot_app'
image: 'springboot_app:1.0'
environment:
JAVA_OPTS: '-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.local.only=false
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.port=9010
-Dcom.sun.management.jmxremote.rmi.port=9010
-Djava.rmi.server.hostname=0.0.0.0
-Dcom.sun.management.jmxremote.ssl=false'
ports:
- '8080:8080'
- '9010:9010'
restart: always
A partir de esta configuración, ya podemos crear un contenedor accesible por JConsole y para este fin hemos habilitado un nuevo puerto, el 9010.
Para acceder a JConsole basta con ejecutar el siguiente comando:
$ jconsole
A continuación, debería aparecer una ventana como la que veréis a continuación:

Para establecer una conexión con nuestro contenedor debemos seleccionar la opción Remote Access y escribir el host y puerto asignado para monitorización.
Para este ejemplo hemos desactivado la autenticación, por lo que podemos dejar en blanco los apartados de usuario y contraseña. También hemos desactivado el acceso por SSL, por lo que es posible que aparezca una ventana emergente para confirmar la conexión.
Una vez efectuada la conexión, accederemos a la sección Memory:

En esta sección podemos visualizar gráficas relativas al consumo de Heap Memory y Non-Heap Memory. También podemos acceder a gráficas específicas de determinados componentes (Code Cache, Metaspace, etc).
Si prestamos atención a la gráfica de Heap Memory, podemos ver como esta se mantiene entorno a los 295Mb. Esto es debido a que el Heap Memory inicial del proceso se establece por defecto en un 25% del máximo disponible (1.2Gb).
Para visualizar el consumo de Non-Heap Memory, seleccionamos el gráfico correspondiente:

Como podemos ver, el uso de Non-Heap Memory asciende hasta estabilizarse entorno a 92Mb.
Contar con una gráfica que muestre el consumo de Non-Heap Memory resulta muy útil ya que, como comentaba anteriormente, calcular el consumo total de este bloque de memoria no es algo directo.
Obtener los máximos valores de las dos gráficas y sumarlos no sería un enfoque correcto para determinar el total de memoria necesario para el proceso, ya que estas gráficas corresponden al consumo de memoria cuando nuestra aplicación Spring Boot está prácticamente ociosa.
Para poder replicar una situación de uso similar a una real sobre nuestra aplicación, vamos a plantear una prueba de carga.
Es importante que la prueba de carga se realice en base a un correcto pronóstico del tráfico que esperamos que tenga nuestra aplicación en producción, de esta manera podemos monitorizar el consumo de memoria del proceso Java en circunstancias de uso similares a las reales. Para este ejemplo vamos a suponer que con 100 usuarios realizando peticiones al mismo tiempo sobre nuestra aplicación durante un tiempo continuado replicamos el tráfico esperado en producción.
Nosotros usamos JMeter para llevar a cabo este tipo de pruebas. A continuación se muestra una captura del proyecto JMeter para la aplicación Spring Boot de ejemplo:

En el lado izquierdo se pueden ver los diferentes tests funcionales sobre nuestra aplicación Spring Boot. Cada test ejecuta una operación y espera que tenga éxito.
En el lado derecho disponemos de una sección que permite configurar una prueba de carga, donde entre las opciones disponibles podemos establecer un número de threads (usuarios) y un loop count, que corresponde con el número de veces que cada usuario, de forma individual, solicita cada operación.
Basándonos en el pronóstico anterior, establecemos 100 usuarios y para replicar un uso continuado añadimos un loop count de 20.
Una vez ejecutamos la prueba podremos ver como las gráficas en JConsole comienzan a cambiar:

El consumo de Heap Memory llega a alcanzar los 550Mb durante la ejecución de la prueba y asciende y desciende de forma repentina en periodos cortos de tiempo, debido a la acción del Garbage Collector. Finalizada la prueba, podemos ver como el consumo de memoria pasa a estabilizarse de nuevo.

La Non-Heap Memory asciende hasta estabilizarse en unos 158Mb, quedando estable en este rango.
Finalmente podemos ver el reporte que da JMeter con los resultados de la prueba de carga, en donde podemos ver el throughput de nuestras operaciones y la tasa de errores, entre otros datos disponibles.

Como se puede ver, la tasa de error es de un 0.00% en todas las operaciones y la suma de sus throughputs es 124.1 operaciones/segundo.
Es importante tener estos valores en cuenta para entender el rendimiento del cual partimos y contrastarlo con el rendimiento después de llevar a cabo la optimización.
Optimizando el consumo de memoria de nuestra aplicación
El consumo de Heap Memory está directamente relacionado al máximo disponible (1.2Gb), ya que el Garbage Collector se activa teniendo en cuenta este máximo. Cuanto mayor sea el máximo disponible de Heap Memory, más largos serán los ciclos del Garbage Collector, haciendo que las limpiezas sean menos habituales y que por tanto el consumo de Heap Memory ascienda más. Esto puede impactar negativamente en el rendimiento de nuestra aplicación.
En nuestro ejemplo, al disponer de un máximo de 1.2Gb de Heap Memory y obteniendo un pico de 550Mb durante la prueba de carga, podemos concluir que la Heap Memory está sobredimensionada, lo cual supone ciclos del Garbage Collector más largos. Por tanto, que el consumo de Heap Memory haya ascendido hasta los 550Mb no quiere decir que nuestro proceso requiera de tanta memoria.
Por otro lado, partiendo de la gráfica de Heap Memory anterior, se puede ver como el Garbage Collector actúa en varias ocasiones haciendo descender el consumo de memoria a aproximadamente 100Mb, un valor muy por debajo de la Heap Memory inicial establecida por defecto. Esto es un indicador de que nuestro proceso no necesita tanta Heap Memory inicial.
Nuestro enfoque fue ir reduciendo gradualmente el tamaño máximo de Heap Memory, buscando que durante la prueba de carga el consumo de memoria se ajustase a valores más próximos al límite y sin penalizar el rendimiento y el éxito de los tests.
Es muy importante no ajustar demasiado el máximo de Heap Memory. Una buena práctica es dejar un poco de espacio para picos de carga extremos que puedan ocurrir. Una aproximación conservadora, dados los resultados obtenidos en este ejemplo, sería repetir la prueba de carga para un máximo de Heap Memory de 512Mb y volver a analizar los resultados.
Con este nuevo máximo de 512Mb, la Heap Memory inicial, al establecerse por defecto en un 25% del máximo, sería de 128Mb. En la gráfica de Heap Memory anterior hemos visto como en algunas ocasiones el Garbage Collector hace descender el consumo de memoria a unos 100Mb. Esto significa que nuestra aplicación no necesita más de 100Mb para mantenerse en ejecución cuando no existe carga, por lo que 128Mb para la Heap Memory inicial son más que suficientes para una primera optimización.
También podemos establecer la Heap Memory inicial a través del flag -Xms, aunque nosotros hemos preferido evitar esta práctica, ya que es recomendable que su valor sea del 25-30% respecto al máximo de Heap Memory.
Teniendo en cuenta el máximo de Heap Memory que establezcamos y la Non-Heap Memory, que en nuestro ejemplo asciende a unos 158Mb, podemos establecer el límite de memoria del contenedor Docker. Es importante no limitar la memoria del contenedor únicamente a la necesaria por el proceso Java, ya que debemos dejar algo de espacio para otros recursos necesarios para el funcionamiento del contenedor.
Después de varias iteraciones sobre el caso de ejemplo, reduciendo el máximo de Heap Memory y analizando resultados, nos quedamos con un valor de 256Mb. Teniendo en cuenta los 158Mb que ocupa la Non-Heap Memory y manteniendo un pequeño margen de 98Mb, terminamos estableciendo un límite de 512Mb para el contenedor.
version: '2.2'
services:
springboot_app:
container_name: 'springboot_app'
image: 'springboot_app:1.0'
mem_limit: 512m
environment:
JAVA_OPTS: '-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.local.only=false
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.port=9010
-Dcom.sun.management.jmxremote.rmi.port=9010
-Djava.rmi.server.hostname=0.0.0.0
-Dcom.sun.management.jmxremote.ssl=false
-Xmx256m'
ports:
- '8080:8080'
- '9010:9010'
restart: always
Ejecutando la prueba de carga sobre el nuevo contenedor, se obtienen los siguientes resultados:

El consumo de Heap Memory llega a picos próximos al máximo establecido, aunque se mantiene cierto margen prudencial para imprevistos. Se puede ver como el Garbage Collector actúa con mayor regularidad para mantener el consumo de memoria por debajo del nuevo máximo.

El consumo de Non-Heap Memory se mantiene exactamente igual. Esto es comprensible, ya que no hemos realizado ninguna optimización sobre esta parte de la memoria.

Los resultados de la prueba de carga son muy positivos, ya que la aplicación sigue respondiendo con una tasa de errores del 0.00% y el throughput se ha mantenido.
Si necesitamos reducir aún más el tamaño de nuestro contenedor, podemos tratar de limitar aún más el tamaño de Heap Memory. En este ejemplo no hemos bajado del límite de 256Mb, ya que en ese caso encontramos errores durante la prueba de carga debido a falta de memoria.
Un siguiente paso puede ser estudiar la opción de reducir el consumo de memoria de componentes de la Non-Heap Memory. Os recomiendo echar un vistazo a este vídeo, en donde se explican muy bien los componentes de la Non-Heap Memory y los flags disponibles para poder limitar sus consumos de memoria.
Conclusión
En este post he compartido con vosotros un posible enfoque para poder optimizar el consumo de memoria de un contenedor Docker que ejecuta un proceso Java.
El primer paso ha sido entender el consumo de memoria de un proceso Java. Después hemos monitorizado nuestro proceso de ejemplo para analizar su consumo de memoria asociado. Finalmente, partiendo del análisis realizado, hemos establecido límites de memoria tanto para el proceso como para el contenedor Docker que lo contiene.
Siguiendo estos pasos podemos obtener un ahorro de recursos notable, repercutiendo directamente en la dimensión de la infraestructura necesaria para alojar nuestros contenedores y reduciendo de esta forma los costes de la misma.
Estoy convencido de que existen formas diferentes de encarar este asunto, por lo que os animo a que compartáis vuestras experiencias y opiniones en la sección de comentarios.
Además, ¡Estaré encantado de resolver cualquier duda que podáis tener!