Cómo aprendimos a explotar Java en Docker

Debajo del capó, hh.ru contiene una gran cantidad de servicios Java que se ejecutan en contenedores acoplables. Durante su operación, encontramos muchos problemas no triviales. En muchos casos, para llegar al fondo de la solución, tuve que buscar en Google durante mucho tiempo, leer las fuentes de OpenJDK e incluso perfilar los servicios en producción. En este artículo intentaré transmitir la quintaesencia del conocimiento adquirido en el proceso.


Límites de CPU


Solíamos vivir en máquinas virtuales kvm con limitaciones de CPU y memoria y, al pasar a Docker, establecimos restricciones similares en cgroups. Y el primer problema que encontramos fue precisamente los límites de la CPU. Debo decir de inmediato que este problema ya no es relevante para las versiones recientes de Java 8 y Java ≥ 10. Si se mantiene al día, puede saltarse esta sección de manera segura.

Entonces, comenzamos un pequeño servicio en el contenedor y vemos que produce una gran cantidad de hilos. O la CPU consume mucho más de lo esperado, tiempo de espera cuánto en vano. O aquí hay otra situación real: en una máquina, el servicio comienza normalmente, y en otra, con la misma configuración, se bloquea, clavado por un asesino OOM.

La solución resulta ser muy simple: solo Java no ve las limitaciones de --cpus establecidas en la --cpus acoplable y cree que todos los núcleos de la máquina host son accesibles. Y puede haber muchos de ellos (en nuestra configuración estándar - 80).
Las bibliotecas ajustan el tamaño de los grupos de subprocesos a la cantidad de procesadores disponibles, de ahí la gran cantidad de subprocesos.
El propio Java escala el número de subprocesos de GC de la misma manera, de ahí el consumo de CPU y los tiempos de espera: el servicio comienza a gastar una gran cantidad de recursos en la recolección de basura, utilizando la mayor parte de la cuota asignada.
Además, las bibliotecas (en particular Netty) pueden, en ciertos casos, ajustar el tamaño de la memoria fuera de la cadera al número de CPU, lo que conduce a una alta probabilidad de exceder los límites establecidos para el contenedor cuando se ejecuta en un hardware más potente.

Al principio, cuando este problema se manifestó, intentamos usar las siguientes rondas de trabajo:
- intentó utilizar un par de servicios libnumcpus , una biblioteca que le permite "engañar" a Java configurando un número diferente de procesadores disponibles;
- indicó explícitamente el número de hilos GC,
- establecer explícitamente límites en el uso de buffers de bytes directos.

Pero, por supuesto, moverse con esas muletas no es muy conveniente, y el cambio a Java 10 (y luego a Java 11), en el que todos estos problemas están ausentes , fue una solución real. Para ser justos, vale la pena decir que también en los ocho, todo estuvo bien con la actualización 191 , lanzada en octubre de 2018. Para entonces ya no era relevante para nosotros, lo que también deseo para ti.

Este es un ejemplo en el que la actualización de la versión de Java brinda no solo satisfacción moral, sino también un beneficio real tangible en forma de operación simplificada y mayor rendimiento del servicio.

Docker y máquina de clase de servidor


Entonces, en Java 10, -XX:ActiveProcessorCount las -XX:ActiveProcessorCount y -XX:+UseContainerSupport (y fueron retroportadas a Java 8), teniendo en cuenta los límites predeterminados de cgroups. Ahora todo fue maravilloso. O no?

Algún tiempo después de mudarnos a Java 10/11, comenzamos a notar algunas rarezas. Por alguna razón, en algunos servicios, los gráficos de GC parecían no usar G1:



Esto fue, por decirlo suavemente, un poco inesperado, ya que sabíamos con certeza que G1 es el recopilador predeterminado, comenzando con Java 9. Al mismo tiempo, no hay tal problema en algunos servicios: G1 está activado, como se esperaba.

Comenzamos a entender y tropezar con algo interesante . Resulta que si Java se ejecuta en menos de 3 procesadores y con un límite de memoria de menos de 2 GB, entonces se considera cliente y no permite usar nada más que SerialGC.

Por cierto, esto afecta solo la elección de GC y no tiene nada que ver con las opciones de compilación -client / -server y JIT.

Obviamente, cuando usamos Java 8, no tuvo en cuenta los límites de la ventana acoplable y pensó que tenía muchos procesadores y memoria. Después de actualizar a Java 10, muchos servicios con límites más bajos comenzaron de repente a usar SerialGC. Afortunadamente, esto se trata de manera muy simple, configurando explícitamente la -XX:+AlwaysActAsServerClassMachine .

Límites de CPU (sí, nuevamente) y fragmentación de memoria


Al observar los gráficos en el monitoreo, de alguna manera notamos que el tamaño del conjunto residente del contenedor es demasiado grande, hasta tres veces más que el tamaño máximo de la cadera. ¿Podría ser este el caso en algún próximo mecanismo complicado que se escala de acuerdo con el número de procesadores en el sistema y no conoce las limitaciones del acoplador?

Resulta que el mecanismo no es del todo complicado: es el conocido malloc de glibc. En resumen, glibc usa las llamadas arenas para asignar memoria. Al crear, a cada hilo se le asigna una de las arenas. Cuando un hilo que usa glibc quiere asignar una cierta cantidad de memoria en el montón nativo a sus necesidades y llama a malloc, entonces la memoria se asigna en la arena asignada a él. Si la arena sirve varios hilos, entonces estos hilos competirán por él. Cuantas más arenas, menos competencia, pero más fragmentación, ya que cada arena tiene su propia lista de áreas libres.

En sistemas de 64 bits, el número predeterminado de arenas se establece en 8 * el número de CPU. Obviamente, esta es una gran sobrecarga para nosotros, porque no todas las CPU están disponibles para el contenedor. Además, para las aplicaciones basadas en Java, la competencia por las arenas no es tan relevante, ya que la mayoría de las asignaciones se realizan en el montón de Java, cuya memoria se puede asignar por completo al inicio.

Esta característica de malloc se conoce desde hace mucho tiempo , así como su solución: utilizar la variable de entorno MALLOC_ARENA_MAX para indicar explícitamente el número de arenas. Es muy fácil de hacer para cualquier contenedor. Aquí está el efecto de especificar MALLOC_ARENA_MAX = 4 para nuestro backend principal:



Hay dos instancias en el gráfico RSS: en una (azul) MALLOC_ARENA_MAX , en la otra (rojo) simplemente reiniciamos. La diferencia es obvia.

Pero después de eso, existe un deseo razonable de descubrir en qué Java generalmente gasta memoria. ¿Es posible ejecutar un microservicio en Java con un límite de memoria de 300-400 megabytes y no tener miedo de que se caiga de Java-OOM o no sea asesinado por un asesino de OOM del sistema?

Procesamos Java-OOM


En primer lugar, debe prepararse para el hecho de que los OOM son inevitables, y debe manejarlos correctamente, al menos para guardar volcados de cadera. Por extraño que parezca, incluso esta simple empresa tiene sus propios matices. Por ejemplo, los volcados de cadera no se sobrescriben: si un volcado de cadera con el mismo nombre ya está guardado, simplemente no se creará uno nuevo.

Java puede agregar automáticamente el número de serie del volcado y la identificación del proceso al nombre del archivo, pero esto no nos ayudará. El número de serie no es útil, porque esto es OOM, y no el volcado de cadera solicitado regularmente: la aplicación se reinicia después de esto, restableciendo el contador. Y la identificación del proceso no es adecuada, ya que en Docker siempre es la misma (la mayoría de las veces 1).

Por lo tanto, llegamos a esta opción:

-XX:+HeapDumpOnOutOfMemoryError
-XX:+ExitOnOutOfMemoryError
-XX:HeapDumpPath=/var/crash/java.hprof
-XX:OnOutOfMemoryError="mv /var/crash/java.hprof /var/crash/heapdump.hprof"


Es bastante simple y con algunas mejoras, incluso puede enseñar a almacenarlo no solo el último volcado de cadera, sino que para nuestras necesidades es más que suficiente.

Java OOM no es lo único que tenemos que enfrentar. Cada contenedor tiene un límite en la memoria que ocupa, y se puede superar. Si esto sucede, el asesino de OOM del sistema mata el contenedor y se reinicia (usamos restart_policy: always ). Naturalmente, esto no es deseable, y queremos aprender a establecer correctamente los límites de los recursos utilizados por la JVM.

Optimizando el consumo de memoria


Pero antes de establecer límites, debe asegurarse de que la JVM no esté desperdiciando recursos. Ya hemos logrado reducir el consumo de memoria mediante el uso de un límite en el número de CPU y la variable MALLOC_ARENA_MAX . ¿Hay alguna otra forma "casi gratuita" de hacer esto?

Resulta que hay un par de trucos más que ahorrarán un poco de memoria.

El primero es el uso de la -Xss (o -XX:ThreadStackSize ), que controla el tamaño de la pila de subprocesos. El valor predeterminado para una JVM de 64 bits es 1 MB. Descubrimos que 512 KB es suficiente para nosotros. Debido a esto, nunca se ha detectado una StackOverflowException, pero admito que esto no es adecuado para todos. Y el beneficio de esto es muy pequeño.

El segundo es el -XX:+UseStringDeduplication (con G1 GC habilitado). Le permite ahorrar en memoria al colapsar filas duplicadas debido a la carga adicional del procesador. La compensación entre la memoria y la CPU depende solo de la aplicación específica y la configuración del mecanismo de deduplicación en sí. Lea el dock y pruebe en sus servicios, tenemos esta opción que aún no ha encontrado su aplicación.

Y finalmente, un método que no es adecuado para todos (pero nos conviene) es usar jemalloc en lugar del malloc nativo. Esta implementación está orientada a reducir la fragmentación de la memoria y un mejor soporte de subprocesos múltiples en comparación con malloc de glibc. Para nuestros servicios, jemalloc proporcionó un poco más de ganancia de memoria que malloc con MALLOC_ARENA_MAX=4 , sin afectar significativamente el rendimiento.

Otras opciones, incluidas las descritas por Alexei Shipilev en JVM Anatomy Quark # 12: Native Memory Tracking , parecían bastante peligrosas o provocaron una degradación notable en el rendimiento. Sin embargo, con fines educativos, recomiendo leer este artículo.

Mientras tanto, pasemos al siguiente tema y, finalmente, intentemos aprender a limitar el consumo de memoria y seleccionar los límites correctos.

Limitar el consumo de memoria: memoria directa heap, no heap


Para hacer todo bien, debe recordar en qué consiste la memoria en general en Java. Primero, veamos los grupos cuyo estado se puede monitorear a través de JMX.

El primero, por supuesto, es moderno . Es simple: -Xmx , pero ¿cómo hacerlo bien? Desafortunadamente, no existe una receta universal aquí, todo depende de la aplicación y el perfil de carga. Para los nuevos servicios, comenzamos con un tamaño de almacenamiento dinámico relativamente razonable (128 MB) y, si es necesario, aumentamos o disminuimos. Para admitir los existentes, hay monitoreo con gráficos de consumo de memoria y métricas de GC.

Al mismo tiempo que -Xmx establecemos -Xms == -Xmx . No tenemos una sobreventa de memoria, por lo que nos interesa que el servicio utilice al máximo los recursos que le dimos. Además, en los servicios ordinarios incluimos -XX:+AlwaysPreTouch y el mecanismo Transparent Huge Pages: -XX:+UseTransparentHugePages -XX:+UseLargePagesInMetaspace . Sin embargo, antes de habilitar THP, lea detenidamente la documentación y pruebe cómo se comportan los servicios con esta opción durante mucho tiempo. No se descartan sorpresas en máquinas con RAM insuficiente (por ejemplo, tuvimos que apagar el THP en los bancos de prueba).

Lo siguiente es no montón . La memoria sin almacenamiento dinámico incluye:
- Metaspace y Compressed Class Space,
- Código de caché.

Considere estas piscinas en orden.

Por supuesto, todo el mundo ha escuchado sobre Metaspace , no hablaré en detalle. Almacena metadatos de clase, código de bytes del método, etc. De hecho, el uso de Metaspace depende directamente del número y el tamaño de las clases cargadas, y puede determinarlo, como hip, solo iniciando la aplicación y eliminando las métricas a través de JMX. Por defecto, Metaspace no está limitado por nada, pero es bastante fácil hacerlo con la -XX:MaxMetaspaceSize .

Compressed Class Space es parte de Metaspace y aparece cuando la -XX:+UseCompressedClassPointers está habilitada (habilitada de manera predeterminada para montones de menos de 32 GB, es decir, cuando puede proporcionar una ganancia de memoria real). El tamaño de este grupo puede estar limitado por la opción -XX:CompressedClassSpaceSize , pero no tiene mucho sentido, ya que Compressed Class Space está incluido en Metaspace y la cantidad total de memoria bloqueada para Metaspace y Compressed Class Space está en última instancia limitada a una -XX:MaxMetaspaceSize .

Por cierto, si observa las lecturas de JMX, la cantidad de memoria no heap siempre se calcula como la suma de Metaspace, Compressed Class Space y Code Cache. De hecho, solo necesita resumir Metaspace y CodeCache.

Por lo tanto, en el no almacenamiento dinámico solo quedó Code Cache , el repositorio de código compilado por el compilador JIT. De forma predeterminada, su tamaño máximo está establecido en 240 MB, y para servicios pequeños es varias veces mayor de lo necesario. El tamaño de la caché de código se puede establecer con la opción -XX:ReservedCodeCacheSize . El tamaño correcto solo se puede determinar ejecutando la aplicación y siguiéndola bajo un perfil de carga típico.

Es importante no cometer un error aquí, ya que la Caché de código insuficiente elimina el código frío y antiguo del caché (la -XX:+UseCodeCacheFlushing habilitada de forma predeterminada), y esto, a su vez, puede conducir a un mayor consumo de CPU y una degradación del rendimiento . Sería genial si pudieras lanzar OOM cuando se desborde Code Cache, para esto incluso hay el -XX:+ExitOnFullCodeCache , pero, desafortunadamente, solo está disponible en la versión de desarrollo de la JVM.

El último grupo sobre el que hay información en JMX es la memoria directa . De forma predeterminada, su tamaño no está limitado, por lo que es importante establecer algún tipo de límite para ello, al menos las bibliotecas como Netty, que utilizan activamente buffers de bytes directos, se guiarán por él. No es difícil establecer un límite con el -XX:MaxDirectMemorySize y, nuevamente, solo el monitoreo nos ayudará a determinar el valor correcto.

Entonces, ¿qué llegamos hasta ahora?

  Memoria de proceso Java = 
     Heap + Metaspace + Caché de código + Memoria directa =
         -Xmx +
         -XX: MaxMetaspaceSize +
         -XX: ReservedCodeCacheSize +
         -XX: MaxDirectMemorySize 


Intentemos dibujar todo en el gráfico y compararlo con el contenedor de acopladores RSS.



La línea de arriba es el RSS del contenedor y es una vez y media más que el consumo de memoria de la JVM, que podemos monitorear a través de JMX.

Cavando más!

Limitación del consumo de memoria: seguimiento de memoria nativa


Por supuesto, además de la memoria heap, no heap y directa, la JVM usa un montón de otras agrupaciones de memoria. La bandera -XX:NativeMemoryTracking=summary nos ayudará a -XX:NativeMemoryTracking=summary con ellos -XX:NativeMemoryTracking=summary . Al habilitar esta opción, podremos obtener información sobre grupos conocidos por JVM, pero no disponibles en JMX. Puede leer más sobre el uso de esta opción en la documentación .

Comencemos con lo más obvio: la memoria ocupada por las pilas de hilos . NMT produce algo como lo siguiente para nuestro servicio:

  Hilo (reservado = 32166 KB, comprometido = 5358 KB)
     (hilo # 52)
     (pila: reservado = 31920 KB, comprometido = 5112 KB)
     (malloc = 185KB # 270) 
     (arena = 61KB # 102) 

Por cierto, su tamaño también se puede encontrar sin Native Memory Tracking, usando jstack y cavando un poco en /proc/<pid>/smaps . Andrey Pangin presentó una utilidad especial para esto.

El tamaño del espacio de clase compartido es aún más fácil de evaluar:

  Espacio de clase compartida (reservado = 17084 KB, comprometido = 17084 KB)
     (mmap: reservado = 17084 KB, comprometido = 17084 KB) 

Este es el mecanismo de intercambio de datos de clase, -Xshare -XX:+UseAppCDS -Xshare y -XX:+UseAppCDS . En Java 11, la opción -Xshare está configurada en auto de manera predeterminada, lo que significa que si tiene el $JAVA_HOME/lib/server/classes.jsa (está en la imagen oficial del acoplador OpenJDK), cargará el mapa de memoria- Ohm al inicio de la JVM, acelerando el tiempo de inicio. En consecuencia, el tamaño de Shared Class Space es fácil de determinar si conoce el tamaño de los archivos jsa.

Las siguientes son las estructuras nativas del recolector de basura :

  GC (reservado = 42137 KB, comprometido = 41801 KB)
     (malloc = 5705KB # 9460) 
     (mmap: reservado = 36432 KB, comprometido = 36096 KB) 

Alexey Shipilev en el manual ya mencionado sobre el seguimiento de memoria nativa dice que ocupan alrededor del 4-5% del tamaño del montón, pero en nuestra configuración para el montón pequeño (hasta varios cientos de megabytes) la sobrecarga alcanzó el 50% del tamaño del montón.

Las tablas de símbolos pueden ocupar mucho espacio:

  Símbolo (reservado = 16421 KB, comprometido = 16421 KB)
     (malloc = 15261KB # 203089) 
     (arena = 1159KB # 1) 

Almacenan los nombres de métodos, firmas, así como enlaces a cadenas internados. Desafortunadamente, parece posible estimar el tamaño de la tabla de símbolos solo después de factum usando Native Memory Tracking.

Lo que queda Según Native Memory Tracking, muchas cosas:

  Compilador (reservado = 509 KB, comprometido = 509 KB)
 Interno (reservado = 1647 KB, comprometido = 1647 KB)
 Otro (reservado = 2110 KB, comprometido = 2110 KB)
 Arena Chunk (reservado = 1712 KB, comprometido = 1712 KB)
 Registro (reservado = 6 KB, comprometido = 6 KB)
 Argumentos (reservado = 19 KB, comprometido = 19 KB)
 Módulo (reservado = 227 KB, comprometido = 227 KB)
 Desconocido (reservado = 32 KB, comprometido = 32 KB) 

Pero todo esto ocupa bastante espacio.

Desafortunadamente, muchas de las áreas de memoria mencionadas no pueden ser limitadas ni controladas, y si pudiera ser, la configuración se convertiría en un infierno. Incluso monitorear su estado es una tarea no trivial, ya que la inclusión de Native Memory Tracking agota ligeramente el rendimiento de la aplicación y no es una buena idea habilitarla para la producción en un servicio crítico.

Sin embargo, por interés, intentemos reflejar en el gráfico todo lo que informa Native Memory Tracking:



No esta mal! La diferencia restante es una sobrecarga para la fragmentación / asignación de memoria (es muy pequeña, ya que usamos jemalloc) o la memoria que asignaron las bibliotecas nativas. Solo usamos uno de estos para el almacenamiento eficiente del árbol de prefijos.

Entonces, para nuestras necesidades, es suficiente limitar lo que podemos: Heap, Metaspace, Code Cache, Direct Memory. Para todo lo demás, dejamos algunas bases razonables, determinadas por los resultados de mediciones prácticas.

Después de ocuparse de la CPU y la memoria, pasamos al siguiente recurso por el cual las aplicaciones pueden competir: los discos.

Java y unidades


Y con ellos, todo es muy malo: son lentos y pueden provocar una opacidad tangible de la aplicación. Por lo tanto, desenlazamos Java de los discos tanto como sea posible:

  • Escribimos todos los registros de aplicaciones en el syslog local a través de UDP. Esto deja alguna posibilidad de que los registros necesarios se pierdan en algún lugar del camino, pero, como lo ha demostrado la práctica, estos casos son muy raros.
  • Escribiremos registros JVM en tmpfs, para esto solo necesitamos montar la ventana acoplable en la ubicación deseada con el /dev/shm .


Si escribimos registros en syslog o en tmpfs, y la aplicación en sí misma no escribe nada en el disco, excepto los volcados de cadera, ¿entonces resulta que la historia con discos puede considerarse cerrada al respecto?

Por supuesto que no.

Prestamos atención al gráfico de la duración de las pausas de detener el mundo y vemos una imagen triste: las pausas de Stop-The-World en los hosts son cientos de milisegundos, y en un host pueden alcanzar hasta un segundo:



¿No hace falta decir que esto afecta negativamente a la aplicación? Aquí, por ejemplo, hay un gráfico que refleja el tiempo de respuesta del servicio según los clientes:



Este es un servicio muy simple, en su mayor parte que da respuestas en caché, entonces, ¿de dónde provienen esos tiempos prohibitivos, comenzando con el percentil 95? Otros servicios tienen una imagen similar, además, los tiempos de espera están lloviendo con una constancia envidiable al tomar conexiones del grupo de conexiones a la base de datos, al ejecutar solicitudes, etc.

¿Qué tiene que ver el disco con él? - usted pregunta Resulta mucho que ver con eso.
Un análisis detallado del problema mostró que surgen largas pausas STW debido al hecho de que los hilos van al punto seguro durante mucho tiempo. Después de leer el código JVM, nos dimos cuenta de que durante la sincronización de subprocesos en el punto seguro, la JVM puede escribir el archivo /tmp/hsperfdata* través del mapa de memoria, al que exporta algunas estadísticas. Las utilidades como jstat y jps usan jstat jps .

Deshabilítelo en la misma máquina con la opción -XX:+PerfDisableSharedMem y ...



Las métricas del muelle de embarcadero se estabilizan:



(, ):



, , , .

?


Java- , , , .

Nuts and Bolts , . , . , , JMX.

, . .

statsd JVM, (heap, non-heap ):



, , .

— , , , , ? . () -, , RPS .

: , . . ammo- . . . :



.

, . , , - , , .

En conclusión


, Java Docker — , . .

Source: https://habr.com/ru/post/450954/


All Articles