Seguro que alguna vez has tenido que desplegar una aplicación java en un docker. Si lo has hecho en cualquier tipo de infraestructura cloud que utilice esta tecnología, te habrás encontrado con la sorpresa de la cantidad de memoria que consume.

Incluso, muchas veces, con una gran cantidad de memoria, al cabo de un tiempo (días o semanas) el docker termina cayéndose como consecuencia de la solicitud de más memoria: el temido OOM Killed (Out of Memory Management).

Antes de meternos en harina con la parte técnica, pongámonos en contexto. Durante mi experiencia profesional he trabajado en diversos proyectos construidos con la arquitectura de microservicios de Spring Cloud Netflix, desplegando en algún cloud basado en Docker (generalmente Openshift). En todos ellos ha existido siempre el problema con el consumo de memoria que hacía la aplicación.

Por si fuera poco, no es una situación que se produzca solo con microservicios funcionales (que se podría achacar a un mal desarrollo o una mala gestión de la memoria que hace el equipo), sino que incluso aplicaciones como eureka-server o config-server elevan sus consumos por encima de lo esperado para aplicaciones de tan pequeño tamaño.

Este post trata todo el proceso llevado a cabo, desde las herramientas que utilizamos para el diagnóstico hasta las diversas soluciones aplicadas. ¡Empecemos!

En todos estos proyectos nunca se nos permitió llegar al fondo del asunto (conseguimos mejoras, pero nunca llegamos a la raíz del problema) y os preguntaréis ¿por qué?

Todos somos conscientes de cómo muchas veces se prima la funcionalidad por encima de la deuda técnica, las ‘ñapas’ por encima de las soluciones reales. ¿Por qué va un cliente a gastar, por ejemplo las hora de trabajo de una persona durante una semana, investigando el problema real de la aplicación si se puede solventar dando 1Gb más de memoria?

Recientemente, y de forma extraordinaria, un cliente solicitó nuestra ayuda para, precisamente, arrojar luz sobre esta situación.

Sus aplicaciones basadas en microservicios consumían altas cotas de memoria y, al cabo de unos días/semanas, terminaban matando el contenedor como consecuencia de estos consumos.

Herramientas utilizadas

Dentro del mundo Java existen diversas herramientas que nos permiten analizar la memoria consumida por la aplicación.

A su vez, la cantidad de memoria consumida por el proceso java está compuesto por varios elementos (heap, garbage collector, threads...). Para nuestra investigación recurrimos a las dos siguientes herramientas (el detalle del uso de estas herramientas queda fuera del ámbito de este artículo y se puede encontrar en sus respectivas páginas).

jConsole

“The JConsole graphical user interface is a monitoring tool that complies to the Java Management Extensions (JMX) specification. JConsole uses the extensive instrumentation of the Java Virtual Machine (Java VM) to provide information about the performance and resource consumption of applications running on the Java platform”, Oracle.

La interfaz gráfica se ve de la siguiente forma:

Podemos ver el consumo de diferentes recursos y, a través de las distintas pestañas, en detalle la memoria, threads… El detalle de su funcionamiento se puede encontrar aquí.

jcmd

“The jcmd utility is used to send diagnostic command requests to the JVM, where these requests are useful for controlling Java Flight Recordings, troubleshoot, and diagnose JVM and Java Applications. It must be used on the same machine where the JVM is running, and have the same effective user and group identifiers that were used to launch the JVM”, Oracle.

El detalle de su funcionamiento se puede encontrar en este enlace.

Configuración

Como primer paso, será necesario configurar nuestra aplicación para poder utilizar las herramientas mencionadas. En concreto, para la utilización de jconsole, añadimos la siguiente configuración a la aplicación:

-Dcom.sun.management.jmxremote.port=1616

-Dcom.sun.management.jmxremote.rmi.port=1616

-Dcom.sun.management.jmxremote.ssl=false

-Dcom.sun.management.jmxremote.authenticate=false

*-Dcom.sun.management.jmxremote.local.only=false -Djava.rmi.server.hostname=localhost

Para jcmd la siguiente:

-XX:NativeMemoryTracking=detail

¡Manos a la obra!

Una vez tenemos nuestras herramientas listas y nuestra aplicación configurada, podemos empezar. El proceso de investigación de este tipo de situaciones es siempre muy caótico y laberíntico. Tanto que en ocasiones nos lleva a puntos sin salida.

¿Cuál fue el recorrido que hicimos? Veámoslo paso a paso.

Java y los CGroups

Docker es una tecnología relativamente joven (en concreto de 2013) y de popularidad aún más reciente, recuerdo oír hablar de ella en 2015.

Esto hace que muchas otras tecnologías/herramientas no la tuvieran en consideración cuando fueron liberadas en el mercado, ya sea porque no existía o porque en ese momento no era ‘nadie’.

Es el caso de Java 8, liberada a comienzos de 2014. Y os estaréis preguntado, ¿y qué tiene que ver Java 8 con Docker y los cgroups? Pues todo, pero para entenderlo empecemos por ver qué son los cgroups:

“Cgroups (abbreviated from control groups) is a Linux kernel feature that limits, accounts for, and isolates the resource usage (CPU, memory, disk I/O, network, etc.) of a collection of processes”, Wikipedia.

¿Y qué tienen que ver unos con otros? Básicamente los cgroups son la tecnología que utilizar Docker para limitar los recursos asignados a un contenedor.

_“Docker Engine on Linux also relies on another technology called control groups (cgroups). A cgroup limits an application to a specific set of resources. _Control groups allow Docker Engine to share available hardware resources to containers* and optionally enforce limits and constraints. For example, you can limit the memory available to a specific container”, Docker.

La información relativa a los cgroups se almacena en el sistema de ficheros. Ahí podremos encontrar todos los recursos gestionados por los cgroups, y dentro de cada uno la composición de los diferentes elementos monitorizados de dicho recurso.

La siguiente captura muestra los recursos gestionados y los diferentes elementos monitorizados de la memoria:

Podemos echar un vistazo a alguno. Por ejemplo, en memory.stat podemos ver los valores actuales de los diferentes elementos de la memoria que monitoriza docker.

El problema entre Java 8 y los cgroups radica en que Java 8, en sus primeras versiones, no soporta los cgroups.

¿Que provoca esto? Que cuando una aplicación java ejecutándose dentro de un contenedor consulta cuánta memoria tiene disponible, obtenga un valor incorrecto. Este valor no será el límite de memoria asignada al contenedor, sino la disponible en el host o nodo correspondiente.

Esto mismo ocurre con algunos comandos de SO. Por ejemplo, si ejecutamos top dentro de un contenedor nos recuperará la memoria disponible del nodo.

En la siguiente captura podemos ver cómo al ejecutar top nos indica que existen 65GB de memoria disponible, que es la correspondiente al nodo:

Esto ocurre no solo con la memoria, sino con los diversos recursos que gestiona cgroups, como puede ser la CPU.

Pero, ¿por qué nos tiene que importar que Java no conozca la memoria real disponible? Porque la JVM asigna unos valores por defecto.

Por ejemplo, al Garbage Collector y al Heap, basándose en los recursos disponibles. En el caso concreto del heap la asignación se hace en base a:

*“Smaller of 1/4th of the physical memory or 1GB”**, *Oracle.

Esto quiere decir que en contenedor (como el arriba indicado) la aplicación Java inicializaría el tamaño de su heap a 1GB (ya que ¼ de 64BG son 16GB y 1GB es menor) independientemente de los recursos disponibles en el contenedor.

Pensemos en lo que esto puede provocar si hubiéramos asignado a nuestro contenedor 512MB de memoria. Tal y como se presenta la situación pensaréis que la solución es sencilla: limitar el tamaño del *Heap *con el parámetro -Xmx.

El problema es que hay una serie de inicializaciones más que también se realizan en base a los recursos disponibles y no solo memoria, sino que afecta también a la CPU.

Es el caso de algunos Garbage Collector, que determinan el número de threads a utilizar en base a los threads de los que dispone el hardware. ¿Cuál es la solución entonces?

Por suerte, esta situación ya está reportada y solventada. En el siguiente enlace podemos ver el bug reportado y las versiones que incluyen la resolución.

A día de hoy (en la fecha en que se ha publicado este post) la resolución se encuentra disponible en la última versión de Java: 8u152. El tema es que el soporte a los cgroups dentro de Java 8 está incluido como funcionalidad experimental (esperemos que en siguientes versiones no sea así), por tanto tendremos que habilitarlo añadiendo los siguientes parámetros a nuestra JVM:

-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap

En nuestro caso fue, además, necesario actualizar la versión de la JVM, porque la que se estaba utilizando no contaba con la resolución de dicho bug.

Tuneando las memorias de Java

Con la actualización de la JVM y habilitando en uso de cgroups, hemos solucionado parte del problema.

Pero el Heap no es la única memoria existente en un proceso Java, por lo que aunque este se limite a ¼ de la memoria disponible en el contenedor, existen otras memorias que pueden provocar que el contenedor se caiga y que, por tanto, tendremos que limitar.

En nuestro caso optamos por monitorizar estas memorias utilizando jConsole y jcmd para asignarles un límite acorde a sus necesidades, así como para detectar cuál de ellas era la que se estaba desviando.

Existen algunas herramientas que realizan esta labor por nosotros. Es el caso de la calculadora de CloudFoundry. Si le proporcionamos parte de la información sobre nuestra aplicación (número de clases , número de threads... además de la memoria del contenedor) nos determinará la configuración de memoria recomendada. A continuación podemos ver un ejemplo de uso:

Llegados este punto es necesario remarcar que es importante que cada uno haga el tuneo de su propia aplicación Java.

Podría incluir los valores que utilizamos nosotros, pero no serviría de nada porque cada aplicación es diferente y estos valores dependerán de cómo sea (número de clases, número de threads, secciones de código más ejecutadas...).

Metaspace

Metaspace contains metadata about the application the JVM is running. It contains class definitions, method definitions, and other information about the program. The more classes you load into your app, the larger metaspace will be.”

Esta memoria no tiene nada de especial. Debido al tipo de datos que almacena su tendencia es a mantenerse si no fija, prácticamente fija. Su limitación se hace con el siguiente parámetro:

-XX:MaxMetaspaceSize=

Java Stack Size

“Thread stacks are memory areas allocated for each Java thread for their internal use. This is where the thread stores its local execution state.” Oracle.

Por Java Stack Size se refiere al tamaño del stack de un thread. A priori podría parecernos un valor despreciable.

¿Cuánto puede llegar a tener de tamaño el stack de un thread? En el caso de JVMs de 64 bits este puede llegar a ser de 1Mb. Por lo que si tienes una aplicación que haga uso intensivo de threads, por ejemplo digamos unos 100, es un valor a tener en cuenta.

Existen diversos sistemas donde se han hecho pruebas y que comentan que 1Mb parece ser un valor excesivo para la mayoría de aplicaciones.

En nuestro caso utilizamos 256k, que es una cuarta parte y que en caso de tener muchos hilos supone una diferencia a tener en cuenta.

El tamaño del thread stack se establece con el parámetro -Xss*.*

CodeCache

_“The Java Virtual Machine (JVM) generates native code and stores it in a memory area called the codecache. The JVM generates native code for a variety of reasons, including for the dynamically generated interpreter loop, Java Native Interface (JNI) stubs, and for Java methods that are compiled into native code by the just-in-time (JIT) compiler. *_The JIT is by far the biggest user of the codecache” Oracle.

Para quien no conozca el JIT (Just-In-Time compiler), es un componente de la JVM que se encarga de la optimización de las aplicaciones Java.

Nuestra aplicación Java, una vez compilada, está compuesta por bytecodes, que serán lo que se ejecute en la máquina correspondiente.

La ejecución de estos bytecodes tiene una ligera sobrecarga debido a que, como ya sabemos, java se compila para ser independiente de la máquina donde se ejecuta, por tanto estos bytecodes no son código nativo.

Lo que hace JIT es transformar estos bytecodes en algo más parecido a código máquina nativo, para que su ejecución sea más eficiente. La JVM mantiene de forma interna un contador de cuántas veces es invocado un método, y una vez este supera cierto número de llamadas es cuando el JIT pasa a la acción.

El JIT dispone de diversos niveles de optimización, de forma que puede realizar varias pasadas sobre un método optimizándolo cada vez más.

Una posible opción para reducir el tamaño del codecache podría ser deshabilitar el JIT, pero debido a la labor que este realiza implicaría una reducción en el rendimiento de nuestra aplicación.

Como vemos al intentar reducir los recursos que consume una aplicación nos podemos encontrar con una reducción en su rendimiento.

El tamaño del codecache se puede establecer con el siguiente parámetro:

-XX:ReservedCodeCacheSize

Hasta aquí hemos acotado prácticamente todo lo que podemos acotar en Java en términos de memoria. Sin embargo, seguíamos viendo cómo las aplicaciones tras cierto tiempo de uso seguían yéndose en cuanto a consumo de memoria.

Para llegar al fondo de todo esto tendremos que aventurarnos en lo desconocido, pero eso ya será tema para el siguiente capítulo de nuestro viaje.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.