Sous le capot, hh.ru contient un grand nombre de services Java exécutés dans des conteneurs Docker. Pendant leur fonctionnement, nous avons rencontré beaucoup de problèmes non triviaux. Dans de nombreux cas, pour aller au fond de la solution, il a fallu longtemps sur Google, lire les sources OpenJDK et même profiler les services en production. Dans cet article, je vais essayer de transmettre la quintessence des connaissances acquises au cours du processus.
Limites du processeur
Nous vivions dans des machines virtuelles kvm avec des limitations de CPU et de mémoire et, en passant à Docker, nous avons défini des restrictions similaires dans les groupes de contrôle. Et le premier problème que nous avons rencontré était précisément les limites du processeur. Je dois dire tout de suite que ce problème n'est plus pertinent pour les versions récentes de Java 8 et Java ≥ 10. Si vous suivez les temps, vous pouvez sauter cette section en toute sécurité.
Donc, nous démarrons un petit service dans le conteneur et voyons qu'il produit un grand nombre de threads. Ou le CPU consomme beaucoup plus que prévu, timeout combien en vain. Ou voici une autre situation réelle: sur une machine, le service démarre normalement, et sur une autre, avec les mêmes paramètres, il se bloque, cloué par un tueur OOM.
La solution s'avère très simple - juste Java ne voit pas les limitations de
--cpus
définies dans le docker et pense que tous les noyaux de la machine hôte lui sont accessibles. Et il peut y en avoir beaucoup (dans notre configuration standard - 80).
Les bibliothèques ajustent la taille des pools de threads au nombre de processeurs disponibles - d'où le grand nombre de threads.
Java lui-même fait évoluer le nombre de threads GC de la même manière, d'où la consommation du processeur et les délais d'expiration - le service commence à dépenser une grande quantité de ressources pour la collecte des ordures, en utilisant la part du lion du quota qui lui est alloué.
De plus, les bibliothèques (Netty en particulier) peuvent, dans certains cas, ajuster la taille de la mémoire off-hip au nombre de CPU, ce qui conduit à une forte probabilité de dépasser les limites définies pour le conteneur lors de l'exécution sur un matériel plus puissant.
Au début, comme ce problème s'est manifesté, nous avons essayé d'utiliser les cycles de travail suivants:
- essayé d'utiliser quelques services
libnumcpus - une bibliothèque qui vous permet de "tromper" Java en définissant un nombre différent de processeurs disponibles;
- a indiqué explicitement le nombre de threads GC,
- fixer explicitement des limites Ă l'utilisation de tampons d'octets directs.
Mais, bien sûr, se déplacer avec de telles béquilles n'est pas très pratique, et le passage à Java 10 (puis à Java 11), dans lequel tous ces problèmes sont
absents , était une vraie solution. En toute honnêteté, il convient de dire que dans les huit aussi, tout allait bien avec la
mise à jour 191 , publiée en octobre 2018. À ce moment-là , c'était déjà hors de propos pour nous, ce que je vous souhaite également.
C'est un exemple où la mise à jour de la version Java apporte non seulement une satisfaction morale, mais aussi un réel bénéfice tangible sous la forme d'un fonctionnement simplifié et de performances de service accrues.
Docker et machine de classe serveur
Ainsi, dans Java 10, les
-XX:ActiveProcessorCount
et
-XX:+UseContainerSupport
sont apparues (et ont été rétroportées vers Java 8), en tenant compte des limites par défaut des
-XX:+UseContainerSupport
. Maintenant, tout était merveilleux. Ou pas?
Quelque temps après notre passage à Java 10/11, nous avons commencé à remarquer quelques bizarreries. Pour une raison quelconque, dans certains services, les graphiques GC semblaient ne pas utiliser G1:
C'était, pour le moins, un peu inattendu, car nous savions avec certitude que G1 est le collecteur par défaut, à commencer par Java 9. En même temps, il n'y a pas un tel problème dans certains services - G1 est activé, comme prévu.
Nous commençons à comprendre et à tomber sur une
chose intéressante . Il s'avère que si Java fonctionne sur moins de 3 processeurs et avec une limite de mémoire inférieure à 2 Go, il se considère comme client et ne permet pas d'utiliser autre chose que SerialGC.
Soit dit en passant, cela n'affecte que le
choix de GC et n'a rien Ă voir avec les options de compilation -client / -server et JIT.
Évidemment, lorsque nous avons utilisé Java 8, il ne tenait pas compte des limites des dockers et pensait qu'il avait beaucoup de processeurs et de mémoire. Après la mise à niveau vers Java 10, de nombreux services avec des limites plus basses ont soudainement commencé à utiliser SerialGC. Heureusement, cela est traité très simplement - en définissant explicitement l'
-XX:+AlwaysActAsServerClassMachine
.
Limites du processeur (oui, encore une fois) et fragmentation de la mémoire
En regardant les graphiques de surveillance, nous avons en quelque sorte remarqué que la taille de l'ensemble résident du conteneur est trop grande - jusqu'à trois fois plus que la taille maximale de la hanche. Cela pourrait-il être le cas dans un autre mécanisme délicat qui évolue en fonction du nombre de processeurs dans le système et ne connaît pas les limites du docker?
Il s'avère que le mécanisme n'est pas du tout délicat - c'est le malloc bien connu de la glibc. En bref, la glibc utilise les soi-disant arènes pour allouer de la mémoire. Lors de la création, chaque thread est affecté à l'une des arènes. Lorsqu'un thread utilisant glibc souhaite allouer une certaine quantité de mémoire dans le tas natif à ses besoins et appelle malloc, la mémoire est allouée dans l'arène qui lui est affectée. Si l'arène sert plusieurs threads, ces threads rivaliseront pour cela. Plus il y a d'arènes, moins il y a de compétition, mais plus il y a de fragmentation, car chaque arène a sa propre liste de zones libres.
Sur les systèmes 64 bits, le nombre d'arènes par défaut est défini sur 8 * le nombre de CPU. De toute évidence, cela représente une énorme surcharge pour nous, car tous les processeurs ne sont pas disponibles pour le conteneur. De plus, pour les applications basées sur Java, la concurrence pour les arènes n'est pas aussi pertinente, car la plupart des allocations se font en Java-tas, dont la mémoire peut être entièrement allouée au démarrage.
Cette fonctionnalité de malloc est connue depuis
très longtemps , ainsi que sa solution - utiliser la variable d'environnement
MALLOC_ARENA_MAX
pour indiquer explicitement le nombre d'arènes. C'est très facile à faire pour n'importe quel conteneur. Voici l'effet de la spécification
MALLOC_ARENA_MAX = 4
pour notre backend principal:
Il y a deux exemples sur le graphique RSS: dans l'un (bleu) nous
MALLOC_ARENA_MAX
, dans l'autre (rouge) nous venons de redémarrer. La différence est évidente.
Mais après cela, il y a un désir raisonnable de comprendre sur quoi Java dépense généralement la mémoire. Est-il possible d'exécuter un microservice sur Java avec une limite de mémoire de 300 à 400 mégaoctets et de ne pas avoir peur qu'il tombe de Java-OOM ou ne soit pas tué par un tueur OOM système?
Nous traitons Java-OOM
Tout d'abord, vous devez vous préparer au fait que les MOO sont inévitables et vous devez les gérer correctement - au moins économiser les décharges de la hanche. Curieusement, même cette simple entreprise a ses propres nuances. Par exemple, les vidages de hanche ne sont pas remplacés - si un vidage de hanche avec le même nom est déjà enregistré, alors un nouveau ne sera tout simplement pas créé.
Java peut
ajouter automatiquement le numéro de série de vidage et l'ID de processus au nom de fichier, mais cela ne nous aidera pas. Le numéro de série n'est pas utile, car il s'agit de MOO, et non du vidage de hanche régulièrement demandé - l'application redémarre après, réinitialisant le compteur. Et l'ID de processus ne convient pas, car dans Docker, il est toujours le même (le plus souvent 1).
Par conséquent, nous sommes arrivés à cette option:
-XX:+HeapDumpOnOutOfMemoryError
-XX:+ExitOnOutOfMemoryError
-XX:HeapDumpPath=/var/crash/java.hprof
-XX:OnOutOfMemoryError="mv /var/crash/java.hprof /var/crash/heapdump.hprof"
C'est assez simple et avec quelques améliorations, vous pouvez même apprendre à le stocker non seulement la dernière version de la hanche, mais pour nos besoins, c'est plus que suffisant.
Java OOM n'est pas la seule chose à laquelle nous devons faire face. Chaque conteneur a une limite sur la mémoire qu'il occupe et elle peut être dépassée. Si cela se produit, le conteneur est tué par le tueur OOM du système et redémarre (nous utilisons
restart_policy: always
). Naturellement, cela n'est pas souhaitable et nous voulons apprendre à définir correctement les limites des ressources utilisées par la JVM.
Optimiser la consommation de mémoire
Mais avant de fixer des limites, vous devez vous assurer que la JVM ne gaspille pas les ressources. Nous avons déjà réussi à réduire la consommation de mémoire en utilisant une limite sur le nombre de CPU et la variable
MALLOC_ARENA_MAX
. Existe-t-il d'autres moyens «presque gratuits» de procéder?
Il s'avère qu'il y a quelques astuces supplémentaires qui permettront d'économiser un peu de mémoire.
Le premier est l'utilisation de l'
-Xss
(ou
-XX:ThreadStackSize
), qui contrôle la taille de la pile pour les threads. La valeur par défaut pour une JVM 64 bits est de 1 Mo. Nous avons découvert que 512 Ko nous suffisaient. Pour cette raison, une StackOverflowException n'a jamais été détectée auparavant, mais j'avoue que cela ne convient pas à tout le monde. Et le bénéfice de cela est très faible.
Le second est l'
-XX:+UseStringDeduplication
(avec G1 GC activé). Il vous permet d'économiser de la mémoire en réduisant les lignes en double en raison de la charge supplémentaire du processeur. Le compromis entre la mémoire et le processeur dépend uniquement de l'application spécifique et des paramètres du mécanisme de déduplication lui-même. Lisez le
dock et testez dans vos services, nous avons cette option n'a pas encore trouvé son application.
Et enfin, une méthode qui ne convient pas à tout le monde (mais qui nous convient) consiste à utiliser
jemalloc au lieu du malloc natif. Cette implémentation vise à réduire la fragmentation de la mémoire et un meilleur support multithreading par rapport à malloc de glibc. Pour nos services, jemalloc a donné un peu plus de gain de mémoire que malloc avec
MALLOC_ARENA_MAX=4
, sans affecter significativement les performances.
D'autres options, notamment celles décrites par Alexei Shipilev dans
JVM Anatomy Quark # 12: Native Memory Tracking , semblaient plutôt dangereuses ou entraînaient une dégradation notable des performances. Cependant, à des fins éducatives, je recommande de lire cet article.
En attendant, passons au sujet suivant et, enfin, essayons d'apprendre à limiter la consommation de mémoire et à sélectionner les limites correctes.
Limiter la consommation de mémoire: tas, non-tas, mémoire directe
Pour tout faire correctement, vous devez vous rappeler en quoi consiste la mémoire en général en Java. Examinons d'abord les pools dont l'état peut être surveillé via JMX.
Le premier, bien sûr, est la
hanche . C'est simple: nous
-Xmx
, mais comment le faire correctement? Malheureusement, il n'y a pas de recette universelle ici, tout dépend de l'application et du profil de charge. Pour les nouveaux services, nous partons d'une taille de segment de mémoire relativement raisonnable (128 Mo) et, si nécessaire, l'augmentons ou la diminuons. Pour prendre en charge ceux existants, il existe une surveillance avec des graphiques de consommation de mémoire et des métriques GC.
En mĂŞme temps que
-Xmx
nous définissons
-Xms == -Xmx
. Nous n'avons pas de survente de mémoire, il est donc dans notre intérêt que le service utilise au maximum les ressources que nous lui avons données. De plus, dans les services ordinaires, nous incluons
-XX:+AlwaysPreTouch
et le mécanisme Transparent Huge Pages:
-XX:+UseTransparentHugePages -XX:+UseLargePagesInMetaspace
. Cependant, avant d'activer THP, lisez attentivement la
documentation et testez le comportement des services avec cette option pendant une longue période. Les surprises ne sont pas exclues sur les machines avec une RAM insuffisante (par exemple, nous avons dû désactiver le THP sur les bancs de test).
Vient ensuite le
non-tas . La mémoire non segmentée comprend:
- Metaspace et espace de classe compressé,
- Cache de code.
Considérez ces pools dans l'ordre.
Bien sûr, tout le monde a entendu parler de
Metaspace , je n'en parlerai pas en détail. Il stocke les métadonnées de classe, le bytecode de méthode, etc. En fait, l'utilisation de Metaspace dépend directement du nombre et de la taille des classes chargées, et vous ne pouvez le déterminer, comme hip, qu'en lançant l'application et en supprimant les métriques via JMX. Par défaut, Metaspace n'est limité par rien, mais il est assez facile de le faire avec l'
-XX:MaxMetaspaceSize
.
L'espace de classe compressé fait partie de Metaspace et apparaît lorsque l'option
-XX:+UseCompressedClassPointers
est activée (activée par défaut pour les tas de moins de 32 Go, c'est-à -dire lorsqu'elle peut donner un gain de mémoire réel). La taille de ce pool peut être limitée par l'option
-XX:CompressedClassSpaceSize
, mais cela n'a pas beaucoup de sens, car l'espace de classe compressé est inclus dans Metaspace et la quantité totale de mémoire verrouillée pour Metaspace et l'espace de classe compressé est finalement limitée à une
-XX:MaxMetaspaceSize
.
Par ailleurs, si vous regardez les lectures JMX, la quantité de mémoire non-tas est toujours calculée comme la
somme de Metaspace, de l'espace de classe compressé et du cache de code. En fait, il vous suffit de résumer Metaspace et CodeCache.
Ainsi, dans le non-tas, seul le
cache de code est resté - le référentiel de code compilé par le compilateur JIT. Par défaut, sa taille maximale est fixée à 240 Mo et, pour les petits services, elle est plusieurs fois plus importante que nécessaire. La taille du cache de code peut être définie avec l'option
-XX:ReservedCodeCacheSize
. La taille correcte ne peut être déterminée qu'en exécutant l'application et en la suivant sous un profil de charge typique.
Il est important de ne pas se tromper ici, car un cache de code insuffisant supprime le code froid et ancien du cache (l'
-XX:+UseCodeCacheFlushing
activée par défaut), ce qui, à son tour, peut entraîner une consommation CPU plus élevée et une dégradation des performances . Ce serait formidable si vous pouviez lancer un MOO lorsque le cache de code déborde, pour cela, il y a même l'
-XX:+ExitOnFullCodeCache
, mais, malheureusement, il n'est disponible que dans la
version de développement de la JVM.
Le dernier pool sur lequel il y a des informations dans JMX est
la mémoire directe . Par défaut, sa taille n'est pas limitée, il est donc important de lui fixer une sorte de limite - au moins des bibliothèques comme Netty, qui utilisent activement des tampons d'octets directs, seront guidées par elle. Il n'est pas difficile de définir une limite à l'aide de l'
-XX:MaxDirectMemorySize
et, encore une fois, seule la surveillance nous aidera à déterminer la valeur correcte.
Alors qu'obtenons-nous jusqu'à présent?
Mémoire de processus Java =
Heap + Metaspace + Code Cache + Direct Memory =
-Xmx +
-XX: MaxMetaspaceSize +
-XX: ReservedCodeCacheSize +
-XX: MaxDirectMemorySize
Essayons de tout dessiner sur le graphique et de le comparer avec le conteneur Docker RSS.
La ligne ci-dessus est le RSS du conteneur et elle est une fois et demie supérieure à la consommation de mémoire de la JVM, que nous pouvons surveiller via JMX.
Creuser plus loin!
Limiter la consommation de mémoire: suivi de la mémoire native
Bien sûr, en plus de la mémoire tas, non tas et directe, la JVM utilise tout un tas d'autres pools de mémoire. L'indicateur
-XX:NativeMemoryTracking=summary
nous aidera Ă les
-XX:NativeMemoryTracking=summary
. En activant cette option, nous pourrons obtenir des informations sur les pools connus de la JVM, mais non disponibles dans JMX. Vous pouvez en savoir plus sur l'utilisation de cette option dans la
documentation .
Commençons par le plus évident - la mémoire occupée par les
piles de threads . NMT produit quelque chose comme ce qui suit pour notre service:
Thread (réservé = 32166 Ko, engagé = 5358 Ko)
(fil # 52)
(pile: réservé = 31920 Ko, engagé = 5112 Ko)
(malloc = 185 Ko # 270)
(arène = 61 Ko # 102)
Soit dit en passant, sa taille peut également être trouvée sans suivi de la mémoire native, en utilisant jstack et en creusant un peu dans
/proc/<pid>/smaps
. Andrey Pangin a présenté une
utilité spéciale pour cela.
La taille de l'
espace de classe partagé est encore plus facile à évaluer:
Espace de classe partagé (réservé = 17084 Ko, engagé = 17084 Ko)
(mmap: réservé = 17084 Ko, engagé = 17084 Ko)
Il s'agit du mécanisme de partage de données de classe,
-Xshare
et
-XX:+UseAppCDS
. Dans Java 11, l'option
-Xshare
est définie sur auto par défaut, ce qui signifie que si vous avez l'
$JAVA_HOME/lib/server/classes.jsa
(elle se trouve dans l'image de docker officielle d'OpenJDK), elle chargera la carte mémoire- Ohm au démarrage de la JVM, accélérant le temps de démarrage. Par conséquent, la taille de l'espace de classe partagé est facile à déterminer si vous connaissez la taille des archives jsa.
Voici les structures natives du
garbage collector :
GC (réservé = 42137 Ko, engagé = 41801 Ko)
(malloc = 5705 Ko # 9460)
(mmap: réservé = 36432 Ko, engagé = 36096 Ko)
Alexey Shipilev dans le manuel déjà mentionné sur le suivi de la mémoire native
dit qu'ils occupent environ 4 à 5% de la taille du tas, mais dans notre configuration pour les petits tas (jusqu'à plusieurs centaines de mégaoctets), les frais généraux ont atteint 50% de la taille du tas.
Beaucoup d'espace peut être occupé par les
tables de symboles :
Symbole (réservé = 16421 Ko, engagé = 16421 Ko)
(malloc = 15261 Ko # 203089)
(arène = 1159 Ko # 1)
Ils stockent les noms des méthodes, des signatures, ainsi que des liens vers des chaînes internes. Malheureusement, il semble possible d'estimer la taille de la table des symboles uniquement après factum à l'aide du suivi de la mémoire native.
Que reste-t-il? Selon Native Memory Tracking, beaucoup de choses:
Compilateur (réservé = 509 Ko, validé = 509 Ko)
Interne (réservé = 1647 Ko, engagé = 1647 Ko)
Autre (réservé = 2110 Ko, engagé = 2110 Ko)
Morceau d'arène (réservé = 1712 Ko, engagé = 1712 Ko)
Journalisation (réservé = 6 Ko, engagé = 6 Ko)
Arguments (réservés = 19 Ko, engagés = 19 Ko)
Module (réservé = 227 Ko, engagé = 227 Ko)
Inconnu (réservé = 32 Ko, engagé = 32 Ko)
Mais tout cela prend beaucoup de place.
Malheureusement, la plupart des zones de mémoire mentionnées ne peuvent être ni limitées ni contrôlées, et si cela était possible, la configuration se transformerait en enfer. Même la surveillance de leur état n'est pas une tâche triviale, car l'inclusion du suivi de la mémoire native draine légèrement les performances de l'application et l'activer en production dans un service critique n'est pas une bonne idée.
Néanmoins, par intérêt, essayons de refléter sur le graphique tout ce que rapporte le suivi de la mémoire native:
Pas mal! La différence restante est une surcharge pour la fragmentation / allocation de mémoire (elle est très petite, car nous utilisons jemalloc) ou la mémoire allouée par les bibliothèques natives. Nous utilisons simplement l'un d'entre eux pour un stockage efficace de l'arborescence des préfixes.
Donc, pour nos besoins, il suffit de limiter ce que nous pouvons: Heap, Metaspace, Code Cache, Direct Memory. Pour tout le reste, nous laissons des bases raisonnables, déterminées par les résultats des mesures pratiques.
Après avoir traité le CPU et la mémoire, nous passons à la prochaine ressource pour laquelle les applications peuvent rivaliser - les disques.
Java et lecteurs
Et avec eux, tout est très mauvais: ils sont lents et peuvent entraîner une matité tangible de l'application. Par conséquent, nous délions autant que possible Java des disques:
- Nous écrivons tous les journaux d'application dans le syslog local via UDP. Cela laisse une certaine chance que les journaux nécessaires seront perdus quelque part en cours de route, mais, comme la pratique l'a montré, de tels cas sont très rares.
- Nous allons écrire les journaux JVM dans tmpfs, pour cela nous avons juste besoin de monter le docker à l'emplacement souhaité avec le
/dev/shm
.
Si nous écrivons des journaux dans syslog ou dans tmpfs, et que l'application elle-même n'écrit rien sur le disque, à l'exception des vidages de la hanche, alors il s'avère que l'histoire avec les disques peut être considérée comme close à ce sujet?
Bien sûr que non.
Nous prêtons attention au calendrier de la durée des pauses d'arrêt du monde et nous voyons une image triste - Les pauses d'arrêt du monde sur les hôtes sont des centaines de millisecondes, et sur un hôte, elles peuvent même atteindre une seconde:
Inutile de dire que cela affecte négativement l'application? Voici, par exemple, un graphique reflétant le temps de réponse du service selon les clients:
Il s'agit d'un service très simple, pour la plupart donnant des réponses mises en cache, alors d'où viennent ces horaires prohibitifs, en commençant par le 95e centile? D'autres services ont une image similaire, en outre, les délais d'attente pleuvent avec une constance enviable lors de la connexion du pool de connexions à la base de données, lors de l'exécution des demandes, etc.
Qu'est-ce que le lecteur a à voir avec ça? - demandez-vous. Il s’avère beaucoup à voir avec cela.
Une analyse détaillée du problème a montré que de longues pauses STW se produisent du fait que les threads vont au point de sécurité pendant une longue période. Après avoir lu le code JVM, nous avons réalisé que lors de la synchronisation des threads sur le safepoint, la JVM peut écrire le fichier
/tmp/hsperfdata*
via la carte mémoire, dans laquelle elle exporte des statistiques. Des utilitaires comme
jstat
et
jps
utilisent
jstat
jps
.
Désactivez-le sur la même machine avec l'option
-XX:+PerfDisableSharedMem
et ...
Les mesures de Jetty Treadpool se stabilisent:
(, ):
, , , .
?
Java- , , , .
Nuts and Bolts , . , . , , JMX.
, . .
statsd JVM, (heap, non-heap ):
, , .
— , , , , ? . () -, , RPS .
: , . . ammo-
. . . :
.
, . , , - , , .
En conclusion
, Java Docker — , . .