Guillaume Fenollar DevOps et SysAdmin Freelance

Guillaume FENOLLAR

Ingénieur Linux/DevOps Indépendant

− Montpellier −

Kubernetes, la Memory Pressure et ses evictions

Sujet sous-évalué et sous-documenté s'il en est, le comportement d'un cluster Kubernetes lorsque la mémoire vive se met à manquer est pourtant un challenge crucial que chaque administrateur cloud devra être à même de relever un jour ou l'autre, et jamais au bon moment, alors autant prendre les devants !

Les limites

Rapide rappel (ou cours) sur les limites, prérequis à tout ce qui va suivre. Les limites sont définies dans .spec.containers[*].ressources d’un pod, et sont donc spécifiées pour chaque conteneur au sein du pod. Elles permettent de contrôler l’utilisation CPU, de stockage éphémère, et de mémoire d’une application, pour donner un garde fou à celle-ci. Il s’agit d’une pratique incontournable, surtout pour les applications potentiellement mal codées ou naturellement gourmandes en RAM (Java, on vous voit).

apiVersion: v1
kind: Pod
metadata:
  name: memory-stress
  labels:
    app: memory-stress
spec:
  containers:
  - command:
    - sh
    - -c
    - stress --vm 2 --vm-bytes 8G --vm-keep
    image: polinux/stress
    name: stress
    resources:
      limits:
        cpu: "50m"
        memory: 20G
      requests:
        cpu: "50m"
        memory: 16G

Cet exemple est le yaml minimal pour lancer un pod pouvant servir à tester une configuration d’éviction telle que celle que nous allons voir tout de suite. Ce pod définit donc une limite CPU de 0.05 cpu/100ms, ainsi qu’une consommation maximale de 20G de mémoire, au delà duquel le conteneur fautif sera redémarré sans préavis pour lui apprendre les bonnes manières.

La Memory Pressure

Les limites permettent donc d’empêcher qu’un pod dépasse ce qu’on lui a défini. Seulement, ces limites sont optionnelles, et surtout, elles ne vont pas empêcher un pod de se retrouver sur un machine déjà assez remplie en RAM. Pour rappel, le scheduler (qui décide quel pod va se retrouver sur quel node) se base seulement sur les requests et non pas les limits. Également, il n’a pas de bille pour savoir quel est l’usage RAM réel d’un node à un moment t. Tout ce qu’il peut faire, c’est s’appuyer sur les estimations des développeurs dont le boulot est alors de définir une valeur “nominale” de consommation de mémoire vive (requests), ainsi qu’une valeur “max” (limits). Seulement, plus la différence entre les requests et les limits de toutes les applications d’un cluster sont grandes, plus il y a de chances que une ou plusieurs de ces applications en viennent à consommer toute la RAM d’un node à un moment donné.

Que se passera-t-il à ce moment-là ? Et bien par défaut, comme sur n’importe quelle machine Linux qui n’a pas d’espace de swap (prohibé avec Kubernetes), ça explose :)

Une fois que la mémoire disponible est basse sur une machine, le Node Controller de Kubernetes s’en rendra compte seulement si un eviction signal est reçu. Cela veut dire qu’il faut qu’un seuil soit dépassé, et ce seuil est celui des Evictions définis par la Kubelet.

Par défaut, la configuration Kubelet est la suivante :

evictionHard:
  memory.available: 100Mi
Ce qui, en options CLI, donne
--eviction-hard=memory.available<100Mi

Cela veut dire qu’un eviction signal sera envoyé par la Kubelet seulement si la mémoire vive disponible descend sous les 100Mi. Mais d’ailleurs, comment est calculée la mémoire vive disponible ? C’est assez simple, en prenant la valeur de capacité mémoire du node (elle même étant la mémoire totale moins les valeurs de systemReserved, kubeReserved et la valeur d’evictionHard), en y retranchant la conso mémoire (le “working-set”) des applications tournant sur le node.

Une fois cet eviction signal reçu, la condition MemoryPressure est alors présente sur le noeud pour une durée minimale de evictionPressureTransitionPeriod (ou en argument du process kubelet : –eviction-pressure-transition-period), qui est par défaut de 5 minutes. La raison alors présentée par la commande kubectl describe node $HOSTNAME sera alors KubeletHasInsufficientMemory.

L’arrivée de la condition

L’apparition de cette condition MemoryPressure n’est pas instantanée. Le process Kubelet fait le tour des ressources de sa machines toutes les 10 secondes, limitant sa réactivité. Il est possible de modifier cela en changeant le paramètre housekeepingInterval (ou –housekeeping-interval en argument de la Kubelet) ainsi que nodeStatusUpdateFrequency (–node-status-update-frequency). La condition est appliquée ensuite dans les quelques secondes qui suivent.

Depuis Kubernetes 1.12, la fonctionnalité TaintNodesByCondition est passée en beta, ça veut dire qu’elle est activée par défaut (antérieurement accessible via une feature gate). Elle permet d’appliquer une teinte de type NoSchedule suite à l’application d’une condition, et MemoryPressure ne déroge pas à la règle. Cela veut dire que dès lors que cette condition est reconnue (pour rappel quand on passe sous la barre des 100Mi par défaut, ce qui est fort heureusement modifiable) et ce jusqu’à ce qu’elle disparaisse, une “taint” va empêcher de nouveaux pods d’être déployés sur le nœud. Une condition de type MemoryPressure disparaîtra quand la mémoire sera remontée au dessus de 100Mi par défaut, soit immédiatement, soit à la durée de evictionPressureTransitionPeriod (ou –eviction-pressure-transition-period) si cette dernière est définie. Si présente, la teinte disparaitra en même temps que la condition.

evictionPressureTransitionPeriod permet de garder un état un minimum de temps défini pour éviter que l’état “flap”, que la condition disparaisse puis réapparaisse trop vite, ce qui peut empirer les problèmes côté scheduling K8S.

L’éviction en action

Une fois qu’une condition est présente, le processus d’éviction des pods va essayer de résoudre le problème par lui même. Pour cela, deux types d’évictions sont disponibles, la soft et la hard eviction. Tous deux mènent à l’envoi d’un eviction signal dont j’ai discuté plus haut.

La hard eviction (evictionHard dans la configuration) définit à partir de quel seuil les pods seront évincés de la machine pour rejoindre un état acceptable. Il s’agit d’un dictionnaire avec comme clés les différents types de ressources surveillés (memory, imagefs ou nodefs), et comme valeur les seuils définis.

Hard evictions et généralités

evictionHard:
    memory.available: 5G    # OU
    memory.available: 15%

Définir une seule de ses valeurs ou la première sera ignorée. Il est donc en effet possible de définir un pourcentage de la valeur totale, comme une valeur absolue, suffixée par une unité de mesure (%, K, M, G…).

Une fois le seuil franchi et l’eviction signal envoyé au cluster, faisant apparaitre la condition et donc la tainte (si TaintNodesByCondition activé), les pods vont alors être passés en revue pour définir une liste des candidats à l’évincement. Il s’agit là d’un sujet à part entière qui sera donc abordé dans un autre article, mais dans les grande lignes, seront supprimés en premier les pods à la priorityClass la plus basse, ceux qui consomment bien plus de mémoire que leur requests définie, et d’autres curseurs de ce type.

Les pods supprimés le sont par la Kubelet et par défaut à la valeur de 0.1 node par seconde. Ce paramètre est modifiable par le paramètre –node-eviction-rate appliquée sur le controller-manager de Kubernetes (et non pas la Kubelet cette fois-ci). Par exemple, si trois nodes sont dans le cluster, nous aurons au maximum 0.3 évictions de pod par seconde, soit donc environ 3 à 4 toutes les 10 secondes. Le comportement change si le cluster (ou la zone) n’est pas considéré «healthy», comprendre si plus de 55% des nodes sont en status notReady, voir la doc pour plus d’informations.

Les pods supprimés passent donc en status Evicted et n’ont pas de Grace Period leur permettant d’être supprimés proprement (avec un signal TERM). Les objets pods resteront néanmois présents sur le cluster même après l’arrêt des conteneurs, jusqu’à leur suppression pour témoigner de l’incident qui vient d’avoir lieu.

Plus de contrôle avec la Soft Eviction

evictionHard spécifie des valeurs par défaut, mais il existe aussi un autre type d’éviction, optionnel celui-ci, le Soft Eviction. Il est en tout point comparable avec le hardEviction, avec les différences suivantes.

Il permet de définir une grace period (periode de transition si vous voulez) via le dict evictionSoftGracePeriod, et ce pour chaque ressource définie (memory.available, imagefs.available…). En fait, cette option est même obligatoire dès lors qu’une conf de softEviction est présente, sinon la Kubelet ne démarrera pas par invalidation de la conf avec l’erreur suivante :

error: failed to run Kubelet: failed to create kubelet: grace period must be specified for the soft eviction threshold`

Exemple de configuration:

evictionSoft:
    memory.available: 10G
evictionSoftGracePeriod:
    memory.available: 30s

Dès le dépassement du threshold défini pour le soft, l’eviction signal est envoyé et l’apparition de la condition se fait comme avec le hardEviction. Seulement, la grace period est appliquée (ici 30 secondes) avant la moindre éviction de pod. Une fois cette grace period dépassée, les pods vont être évincés, mais cette fois avec une TerminationGracePeriod de maximum la valeur evictionMaxPodGracePeriod, si défini. Ce qui veut dire que Kubernetes va donner du temps au pod de mourir proprement (couper ses connexions, fermer ses fichiers…). Si le pod définit également TerminationGracePeriod en plus de evictionMaxPodGracePeriod, la plus basse valeur des deux est retenue.

Les concepts de Node Eviction Rate ainsi que l’evictionPressureTransitionPeriod sont appliqués, comme sous la hardEviction. Bien sûr, si pendant l’evictionSoftGracePeriod, le seuil hardEviction est franchi, alors les évictions de pod vont commencer immédiatement.

Et si l’éviction ne suffit pas ?

Il se peut que l’eviction soit trop lente à réagir ou que les pods se mettent à consommer trop de mémoire trop vite et qu’un node se retrouve à court de mémoire vive. Auquel cas il se passe comme sur n’importe quelle machine Linux, l’OOM est invoqué et va tuer des processus après avoir fait tout plein de calculs pour essayer de faire le meilleur choix. Kubernetes applique un score oom à tous ses pods par prévention, pour que les pods les plus critiques (selon leur field QoS, discuté dans un article à venir) soient préservés.

Si OOM il y a, le describe du node (kubectl describe node $HOSTNAME) indiquera comme event «System OOM Encountered». Ce message reviendra à tous les restart de la Kubelet, jusqu’au reboot de la machine, car ce process utilise un oomwatcher lisant /dev/kmsg.

Si la situation ne s’arrange pas, tout ce que vous pouvez faire dans ces cas-là, c’est jouer avec les curseurs jusqu’à trouver la bonne configuration, vous pouvez par exemple :

  • Définir des limites agressives de consommation mémoire aux pods les plus mal optimisés
  • Définir des seuils evictionHard et evictionSoft plus bas
  • Agrandir la différence entre evictionHard et evictionSoft, pour appliquer une teinte plus tôt
  • Augmenter la valeur du node-eviction-rate au niveau de controller-manager

Expérimentez sur un cluster de test et voyez ce qui vous sied le mieux. Vous pouvez utiliser le pod cité en haut de l’article pour mettre à mal la conso mémoire de votre cluster de test et observer ce qui se passe.

Une configuration pour les contrôler tous

Je ne saurai assez vous recommander d’appliquer ce genre de configuration

evictionHard:
    memory.available: 10%
evictionSoft:
    memory.available: 20%
evictionSoftGracePeriod:
    memory.available: 30s
evictionPressureTransitionPeriod: 5m
evictionMaxPodGracePeriod: 15

Tout va surtout dépendre du type d’applications que vous avez sur votre cluster. Si par exemple il s’agit d’applications Java très gourmandes en mémoire et assez imprévisible (oui, j’en ai beaucoup souffert), n’hésitez pas à relever ces seuils jusqu’à trouver le mix parfait. À l’inverse, si vous ne démarrez que des petits conteneurs à allure modérée, vous pouvez baisser ces seuils sensiblement pour profiter du maximum de RAM de vos machines worker.