Cómo optimizar tu aplicación Java en Docker (2/2)

En nuestro anterior post hablamos de las diferentes memorias que componen la JVM y de la problemática de Java 8 y los cgroups.

Al final de nuestro viaje habíamos acotado todas las memorias, pero nos seguíamos encontrando en la situación en que después de cierto tiempo los consumos seguían excediendo los límites establecidos.

Habíamos llegado a una situación que empezaba a tomar matices de Expediente X. El proceso Java seguía consumiendo más memoria de la que reportaban tanto jConsole como jcmd. ¿Cómo era eso posible? Fue entonces cuando tocó adentrarse en lo desconocido…

Tuvimos que bajar al siguiente nivel. No era suficiente con ver cuánto consumía cada memoria de la aplicación, era necesario saber de qué estaba compuesta cada una.

Empezamos entonces a inspeccionar las regiones de memoria del SO utilizando para ello el comando pmap. Os dejo un extracto de la salida de este comando para que os hagáis una idea del nivel de detalle al que llega:

sh-4.2$ pmap -x 1

1:   java -Djava.security.egd=file:/dev/./urandom -Xdebug -Xrunjdwp:server=y,transport=dt_socket,address=8000,suspend=n -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=1616 -Dcom.sun.management.jmxremote.rmi.port=1616 -Dcom.s

Address           Kbytes RSS Dirty Mode  Mapping

0000000000400000       4 4 0 r-x– java

0000000000600000       4 4 4 r—- java

0000000000601000       4 4 4 rw— java

0000000001641000   93056 92908 92908 rw—   [ anon ]

00000000f6a00000  112640 112640 112640 rw—   [ anon ]

00000000fd800000   48000 47940 47940 rw—   [ anon ]

00000001006e0000    3200 0 0 —–   [ anon ]

00007ff3e0000000   43940 2048 2048 rw—   [ anon ]

00007ff3e2ae9000   21596 0 0 —–   [ anon ]

00007ff3e5f9c000     512 320 320 rw—   [ anon ]

00007ff3e601c000    1548 0 0 —–   [ anon ]

00007ff3e793a000     500 320 0 r-x– libfreeblpriv3.so

00007ff3e79b7000    2044 0 0 —– libfreeblpriv3.so

00007ff3e7bb6000       8 8 8 r—- libfreeblpriv3.so

00007ff3e7bb8000       4 4 4 rw— libfreeblpriv3.so

00007ff3e7bb9000      16 16 16 rw—   [ anon ]

00007ff3e7bbd000     232 148 0 r-x– libnspr4.so

00007ff3e7bf7000    2044 0 0 —– libnspr4.so

00007ff3e7df6000       4 4 4 r—- libnspr4.so

00007ff3e7df7000       8 8 8 rw— libnspr4.so

00007ff3e7df9000       8 4 4 rw—   [ anon ]

00007ff3e7dfb000      16 8 0 r-x– libplc4.so

00007ff3e7dff000    2044 0 0 —– libplc4.so

00007ff3e7ffe000       4 4 4 r—- libplc4.so

00007ff3e7fff000       4 4 4 rw— libplc4.so

00007ff3e8000000   65536 65536 65536 rw—   [ anon ]

00007ff3ec00f000      12 0 0 —–   [ anon ]

00007ff3ec012000     248 160 160 rw—   [ anon ]

00007ff3ec050000      12 0 0 —–   [ anon ]

00007ffea15c7000     144 44 44 rw—   [ stack ]

00007ffea15f4000       8 4 0 r-x–   [ anon ]

ffffffffff600000       4 0 0 r-x–   [ anon ]

—————- ——- ——- ——-

total kB          751684 527836 513280

Y así cientos y cientos de líneas con regiones de memoria. Como ya habréis supuesto, no es precisamente fácil ni agradable navegar en todo ese chorro de texto.

Después de tiempo y tiempo revisando, observamos que, curiosamente, existían varias regiones de memoria que reservaban bloques de alrededor de 64MB, lo que abre la puerta a nuestro siguiente apartado.

Arenas

Antes de que os lo preguntéis, no, yo tampoco había oído nada similar hasta entonces, pero ahí estaban esos bloques de 64MB.

Además, jcmd las mostraba claramente. En la siguiente captura podréis verlas si os fijáis detenidamente en las secciones de Thread, Compiler y Symbol:

Toda la información que encontrábamos al respecto no nos hablaba exactamente de una memoria Java, sino de un componente de glibc. Para quien no la conozca:

“The GNU C Library provides many of the low-level components used directly by programs written in the C or C++ languages. Many programming languages use the GNU C Library indirectly including C#, Java, Perl, Python, and Ruby (interpreters, VMs, or compiled code for these languages use glibc directly).” Glibc Website.

Si investigamos un poco, nos encontramos que glibc es la librería para la reserva de memoria nativa en la mayoría de distribuciones Linux.

Empezábamos a pisar terreno resbaladizo, nos hemos salido del ámbito de nuestra aplicación que, a priori, era donde teníamos que trabajar y estábamos ya entrando en las librerías del SO.

Y una vez más, ¿qué tiene que ver glibc con las arenas? Primero entendamos cómo hace glibc la reserva de memoria. Como se indica en este artículo:

“The glibc malloc function allocates blocks of address space to callers by requesting memory from the kernel … the two ‘places’ from where the address space is obtained are ‘arenas’ and ‘anonymous memory maps’ … the arena, which is nothing but a contiguous block of memory obtained from the kernel. The difference from the anon maps is that one anon map fulfills only one malloc request while an arena is a scratchpad that glibc maintains to return smaller blocks to the requestor.”

En esencia, una arena es un bloque de memoria que gestiona glibc y que sirve como ‘pool’ de memoria para solicitudes de pequeño tamaño realizadas por la aplicación.

Pero, ¿por qué se utilizan las arenas? Surgen como una evolución sobre el anterior modelo con la finalidad de mejorar el rendimiento en los procesos multithread. El nuevo modelo consiste en asignar una única arena a cada hilo de ejecución, de forma que cada hilo trabaja en su propia arena.

El modelo anterior utilizaba una única arena, que era compartida por todos los hilos, pero el proceso de gestión de esta única arena era muy pesado. Fue el coste de este proceso de gestión el que provocó el surgimiento del nuevo modelo:

“In theory, the libc change may improve performance of native processes that have highly concurrent malloc invocations, which could be many Linux processes. Java is somewhat unique in that it tends to make a few, large malloc calls (both for the Java heap and “segments” for JIT, classes, etc.) and so may be uniquely negatively affected by this change.” Blog IBM.

Este nuevo modelo mejora el rendimiento de las reservas de memoria. Pero, ¿y cuál es su coste? Imagino que lo habréis adivinado, el síntoma que estamos viendo: un mayor consumo de memoria; y, lógicamente, estando estas reservas vinculadas a los hilos, la situación será peor en aplicaciones en que se crean y destruyen muchos hilos.

El trasfondo del problema reside en la cantidad de arenas que podemos llegar a tener y el tamaño de las mismas.

Para un sistema de 64-bits se asigna 8 veces el número de cores existententes. Entonces un contenedor con un core asignado tendrá como máximo 8 arenas.

El tamaño máximo de una arena en un sistema de 64-bits es de 64MB. Si cruzamos la información nos pone en una situación donde la memoria podría incrementarse en 512 (64 * 8) MB por cada core que tengamos. Eso ya no es moco de pavo.

Por suerte para nosotros existe una forma sencilla de decirle a glibc que limite el número máximo de arenas que se pueden utilizar: utilizando la variable MALLOC_ARENA_MAX.

Ahora la duda que teníamos era saber cuántas arenas debíamos utilizar. Para ello nos basamos en este estudio de Heroku sobre sus stacks, comparando el rendimiento de los mismos con diferentes configuraciones de dicha variable.

Como conclusión, el uso de 2 arenas no introduce casi penalización en memoria con respecto al uso de 1, pero sí produce una mejora considerable en los tiempos de respuesta.

Debíamos tener en cuenta que el hecho de limitar las arenas reducirá nuestro consumo de memoria, pero también penalizará el rendimiento de la aplicación. Por supuesto, lo más aconsejable es que cada uno realice pruebas de rendimiento sobre sus microservicios para determinar el mejor valor.

Como comentamos previamente de forma general, todo tiene a un coste, y asociado a la limitación de memoria viene casi siempre un peor rendimiento de la aplicación. Heroku llegó a la misma conclusión durante estas pruebas:

“Not setting a value for MALLOC_ARENA_MAX gives default glibc behavior and has the best performance but also consumes the most memory.”

Con esto podríamos pensar que todos nuestros problemas estaban resueltos. Hemos encontrado la aguja en el pajar, sin embargo, nada más lejos de la realidad.

Si bien es cierto que hemos centrado el tiro, el proceso java sigue consumiendo más memoria de la que herramientas como jcmd reportan y eso nos lleva al siguiente y último punto.

Fragmentación de memoria en glibc

Aquí, una vez maś, debemos volver a las clases de Sistemas Operativos. ¿Recordáis que en una de las definiciones se menciona que glibc utiliza la función malloc() (“The glibc malloc function allocates blocks of address space to callers by requesting memory from the kernel”)?

Pues bien, esta función utiliza internamente otras dos:

  • brk(): para solicitar pequeñas regiones de memoria
  • mmap(): para solicitudes de grandes regiones de memoria

Para entender cuál es el problema de la fragmentación y cuándo se produce, es necesario que entendamos cómo se comportan estas dos funciones y cómo realizan ambas la gestión de la memoria.

brk() funciona aumentando el tamaño de un área de memoria que ya teníamos reservada añadiendo un área contigua. Mientras, mmap() lo que hace es reservar un nuevo bloque de memoria. La siguiente imagen muestra la diferencia de funcionamiento entre ambas funciones:

El motivo por el que se utilizan dos funciones diferentes, es que mmap() tiene un rendimiento peor debido a que obtiene la memoria directamente del SO por cada petición.

Este rendimiento no es un inconveniente cuando se hacen grandes solicitudes de memoria, porque estas no se producen tan a menudo. Sin embargo, las solicitudes de pequeñas regiones suelen ser muy habituales.

El problema de la fragmentación es debido a la gestión de memoria que hace brk(), que reserva una sección de memoria que llama Heap (no confundir con el Heap de Java) para utilizar como pool y por cada pequeña solicitud de memoria asigna un espacio libre de este pool.

Cuando la aplicación libera esa memoria, ésta es devuelta al Heap y no al SO (a diferencia de mmap()) para poder reutilizarla para futuras solicitudes.

De esta forma, por cada solicitud de memoria no tenemos la penalización de una llamada al SO. El problema es que algunas de las regiones de memoria liberadas son demasiado pequeñas como para que se puedan volver a asignar quedando en la práctica inutilizables.

Esta situación, que también se produce con el Java Heap, es solucionada haciendo que el garbage collector utilice un algoritmo de compactación de Heap. Este agrupa todas las pequeñas secciones de memoria libres para poder volver a presentarlas como una gran sección y así poder continuar utilizándolas.

El proceso es explicado en detalle en la documentación de Oracle:

“Objects that are allocated next to each other will not necessarily become unreachable (“die”) at the same time. This means that the heap may become fragmented after a garbage collection, so that the free spaces in the heap are many but small, making allocation of large objects hard or even impossible. Free spaces that are smaller than the minimum thread local area (TLA) size can not be used at all, and the garbage collector discards them as dark matter until a future garbage collection frees enough space next to them to create a space large enough for a TLA.

To reduce fragmentation, the JRockit JVM compacts a part of the heap at every garbage collection (old collection). Compaction moves objects closer together and further down in the heap, thus creating larger free areas near the top of the heap.

The size and position of the compaction area as well as the compaction method is selected by advanced heuristics, depending on the garbage collection mode used.”

Si buscamos un poco han existido diversos problemas con la gestión de la memoria que se realiza en glibc a lo largo del tiempo en sus diferentes versiones. Este y este son solo dos casos que representan dichos problemas

El problema es que brk() no tiene algoritmo de compactación y, por tanto, muchas de esas pequeñas regiones de memoria quedan inutilizables.

While heap allocators can coallesce adjacent free chunks, program allocation patterns, malloc configuration, and malloc heap allocator design limitations mean that there are likely to be free chunks of memory that are unlikely to be used in the future. Blog IBM.

Eso explica por qué existe esa diferencia entre la memoria consumida que reporta la aplicación en sí misma a través de herramientas como jcmd y la que se reporta del proceso java por herramientas como el comando top.

Cuando la aplicación libera una pequeña región de memoria, ésta registra que dicha región ya no está en uso.

Sin embargo, al ser una región pequeña y no poder brk() compactarla, esa región no podrá volver a ser asignada a la aplicación, pero tampoco puede ser devuelta al SO. A efectos de SO, esa región sigue asignada al proceso aunque este no la esté usando y, debido a su tamaño nunca, le podrá volver a ser asignada quedando inutilizable para siempre.

La acumulación de estas pequeñas regiones de memoria inutilizables a lo largo del tiempo es lo que a la larga generará esa diferencia entre la memoria consumida reportada por la aplicación y la reportada por el contenedor / SO.

La pregunta clave es ¿cómo solucionamos esto? Una vez más las variables de entorno vienen a nuestro rescate. Glibc dispone de la variable M_MMAP_TRESHOLD que, como su nombre indica, determina a partir de qué tamaño de región de memoria se utiliza mmap() en lugar de brk().

En el comportamiento por defecto (si no se asigna valor a M_MMAP_TRESHOLD) se utiliza de inicio un valor de 128KB, que irá variando según las necesidades de memoria de la aplicación.

Si se detecta que empezamos a pedir muchas secciones de memoria por encima del tamaño del treshold (es decir, que utilicen mmap()), se actualizará el valor con el objetivo de que la mayor parte de peticiones se resuelvan utilizando brk() y por tanto el heap.

NOTA: existe un valor que nos permite limitar el tamaño máximo que puede llegar a alcanzar el treshold: DEFAULT_MMAP_TRESHOLD_MAX. Aquí podemos ver todas las variables de entorno que nos permiten configurar el comportamiento de glibc.

Lo que hacemos al establecer el valor de M_MMAP_TRESHOLD es, además de darle un valor como tal, fijarlo, de forma que este no variará acorde al comportamiento de nuestra aplicación.

En lo que se refiere al rendimiento, hay que tener en cuenta que si bien nuestro proceso consumirá menos memoria (ya que no se producirá tanta fragmentación), el proceso de reserva de memoria se hará más costoso.

Además del coste de las llamadas al SO, este tendrá que reservar estas regiones y rellenarlas con 0s. Además, estas deben ser múltiplos del tamaño de página de memoria (supongamos por ejemplo 4KB).

Una vez aplicado este cambio, nos encontramos con una curiosa situación: el espacio residente en memoria era incluso inferior a lo que reportaba jcmd. En las siguientes capturas podemos ver cómo si bien jcmd reporta unos tamaños de reserved y commited de 336MB y 291MB respectivamente, el espacio residente solo llega a 216MB:

¿Cómo puede ser esto posible? ¿Acaso los valores que se están reflejando son incorrectos? Para entender el por qué de esta situación debemos ver qué representan los valores “reserved” y “commited” una vez más, a nivel de SO.

En este hilo de stackoverflow lo explican muy bien:

  • “Stack memory (unlike the JVM heap) seems to be precommitted without becoming resident and over time becomes resident only up to the high water mark of actual stack usage.”
  • malloc/mmap is lazy unless told otherwise. Pages are only backed by physical memory once they’re accessed.
  • Committed: Address ranges that have been mapped with something other than PROT_NONE. They may or may not be backed by physical or swap due to lazy allocation and paging.
  • Reserved: The total address range that has been pre-mapped via mmap for a particular memory pool.
  • The reserved − committed difference consists of PROT_NONE mappings, which are guaranteed to not be backed by physical memory.

En resumen, existen regiones de memoria que si bien son reservadas por la aplicación, no llegan a ser residentes realmente hasta que se necesite de su uso.

Esto explica por qué nuestro proceso tiene más memoria reservada que la que está realmente consumiendo. De hecho, la memoria residente puede ser inferior a la commited como se desprende de los comentarios.

Con esto hemos alcanzado el punto final de nuestro trayecto. Hemos conseguido que la memoria residente no llegue a exceder nunca la memoria reservada por el proceso. De esta forma no existirán solicitudes de memoria por encima de la capacidad del contenedor, lo que provocaría que se matara el proceso.

Además hemos comprendido el porqué de esta situación y el motivo que llevaba a que se produjera al cabo de días o semanas.

Versiones utilizadas

Creo que es necesario mencionar las versiones en las que detectamos estos problemas, porque puede que algunos de los lectores de este post pueden verse en la necesidad de saber si estas trabas afectan a sus sistemas:

  • JVM: versión 8.
  • Imagen Docker: Red Hat Enterprise Linux 7.3 y 7.4
  • Glibc 1.17

Como es de suponer la versión de la librería glibc es la que incorpora la imagen RHEL por defecto, aunque no realicemos ninguna modificación sobre la misma.

De todas formas, como ya hemos dicho, esta librería (en la versión que sea) es ampliamente utilizada en las distribuciones Linux.

Consideraciones generales

En este apartado deseo remarcar una serie de puntos que si bien no he mencionado a lo largo del artículo es necesario que cualquiera lleve a cabo si desea reproducir este proceso de análisis o cualquier otro con su aplicación:

  • Durante cada etapa debemos realizar pruebas de rendimiento para ver cómo afecta cada cambio al área que sufre esta modificación, además de al conjunto de la aplicación. Aunque podríamos ver una mejora en un determinado área a coste de una penalización en otra. Aquí es importante remarcar que algunas de las mejoras solo podrán evaluarse al cabo de periodos largos de tiempo, es el caso de la fragmentación de memoria, donde pueden ser necesarios días o incluso semanas para ver sus resultados.
  • Acotar los recursos de memoria disponibles va a introducir una penalización en el rendimiento en nuestra aplicación, tendremos que ver qué nos compensa. Habrá situaciones en las que queramos tener el rendimiento óptimo al coste que sea, mientras que en otras podremos optar por buscar la máxima eficiencia en el uso de los recursos (podría ser el caso de microservicios como eureka o config-server). Esto dependerá tanto de qué aplicaciones estemos hablando, cómo de qué situaciones (siendo ambos casos variables a lo largo del tiempo).

Agradecimientos

A Norman José Suárez por proporcionarme la información para ponerme en contexto con las diferentes memorias Java y su cálculo en diversas soluciones de contenedores.

A Webster de la Rosa, quien me acompañó durante todo el proceso de investigación y quien fue indispensable para la llegada a buen puerto.

Fuentes

Abraham Rodríguez actualmente desarrolla funciones de ingeniero backend J2EE en Paradigma donde ya ha realizado diversos proyectos enfocados a arquitecturas de microservicios. Especializado en sistemas Cloud, ha trabajado con AWS y Openshift y es Certified Google Cloud Platform Developer. Cuenta con experiencia en diversos sectores como banca, telefonía, puntocom... Y es un gran defensor de las metodologías ágiles y el software libre."

Ver toda la actividad de Abraham Rodríguez

Escribe un comentario