K8S : Gestion de ressources

La Scalabilité horizontale que permet Kubernetes est l’une de ses fonctionnalités les plus intéressantes mais scaler horizontalement implique plus de pods, ce qui veut dire aussi plus de processus et donc la demande et la consommation de plus de ressources.
Les ressources ne sont pas illimitées (Mémoire, Espace disque..), si le nœud est surchargé, le Kernel n’aurait plus le choix que de tuer certains de ces processus pour libérer de l’espace (OOM Kill).

Kubernetes nous permet d’avoir un niveau de contrôle sur la consommation de ressources, ainsi que de prioriser certains pods par rapport à d’autres afin d’éviter, en cas de surcharge, que le Kernel tue des pods qui peuvent être critiques, ceci se fait en définissant trois niveaux de QoS à l’aide des limits et requests ainsi que des quotas de ressources, alors qu’est ce que c’est :

Types de ressources

Avant de parler des classes QoS et comment cela est défini dans Kubernetes, il est important de distinguer deux types de ressources :

  • Compressibles :
    Ce sont les ressources qui sont “inépuisables” du moment qu’on a le temps pour attendre qu’ils soient de nouveau disponibles (ex : CPU, Réseau). En cas de surcharge, on peut toujours avoir un accès limité à ces ressources (Throttling), mais avec un risque de latence et de famine.

  • Non compressibles :
    Ce sont les ressources limitées, si toutes les ressources disponibles sont consommées l’application ne pourra plus en avoir plus (ex : Mémoire, Disque..), En cas de surcharge, certains processus peuvent être tués (kill, OOM), si le système a besoin de libérer des ressources.

Classes QoS

Dans Kubernetes une ressource peut être : Demandée, Allouée, ou Consommée. K8S nous permet de spécifier au niveau du container ces exigences à travers les limits, les requests et les quotas.

  • Requests :
    C’est la quantité de ressource (cpu ou mémoire) demandée par un container. Les Requests sont évalués au moment du scheduling, par conséquent la somme de toutes les requests de mémoire ne peut pas dépasser la mémoire totale allouable (allocatable) du nœud. En ce qui concerne le cpu, la somme des requests ne peut pas dépasser la taille du cpu du nœud.
    Le scheduler choisi le nœud le plus adéquat pour le pod en fonction de ses requests. Ainsi la quantité de ressource demandée sera allouée au container/pod, il s’agit donc d’une "soft limit".

  • Limits :
    C’est le seuil maximal de ressources consommées que peut atteindre un pod. C’est donc une "hard limit" qui influence le cgroup. La valeur de limits est considérée au runtime par le Kubelet. Un container/pod qui dépasse ses limits de mémoire sera automatiquement tué, il peut être redémarré ou pas par la suite selon sa restartPolicy. Si on dépasse les limits de CPU alors le pod va être "throttle".

À noter que :
- Donner une valeur au limit suffit pour qu’elle soit assignée aux requests s’ils ne sont pas définis.
- Si on ne spécifie pas la valeur de limits, alors elle est considérée comme infinie (unbounded).

En fonction des limits et des requests trois niveaux de QoS peuvent être définis au niveau du pod (BestEffort < Burstable < Guaranteed). Si le nœud est surchargé Kubernetes se base sur ces niveaux de QoS pour décider quels sont les pods à sacrifier en premier.

BestEffort requests et limits non définis

Tant qu’il y a de la mémoire dans le nœud ces pods peuvent consommer jusqu'à la mémoire totale allouable.
Ils sont les premiers candidats à la terminaison en cas de surcharge.

      containers:
      - image: googlecontainer/echoserver
        name: besteffort

Burstable requests<limits

Ces pods peuvent consommer plus de ressources disponibles tant que ça ne dépasse pas leurs limits.
Si le système a besoin de plus de ressources, ces pods sont considérés de deuxième priorité , et seront terminés s’il dépassent leurs requests et qu’il n y a pas de pod moins prioritaire (BestEffort) à tuer.

      containers:
      - image: googlecontainer/echoserver
        resources:
          requests:
            memory: "500Mi"
          limits:
            memory: "2Gi"
            cpu : "100m"
        name: burstable
      containers:
      - image: googlecontainer/echoserver
        resources:
          requests:
            memory: "500Mi"
          limits:
            memory: "2Gi"
        name: burstable_1
      - image: googlecontainer/echoserver
        resources:
          limits:
            memory: "2Gi"
            cpu : "100m"
        name: burstable_2

Guaranteed requests=limits

Ces pods sont considérés de première priorité et ne seront tués que s’ils dépassent leur limits.

      containers:
      - image: googlecontainer/echoserver
        resources:
          limits:
            memory: "2Gi"
            cpu: "100m"
        name: guaranteed

Si la somme totale des limits de tous les pods est supérieure à la mémoire allouable du nœud, alors on dit que le nœud est overcommitted. L'overcommitment part de l’hypothèse que tous les pods ne vont pas atteindre leurs limites en même temps.

Cela qui peut malheureusement arriver (par exemple quand les limits de mémoire sont trop optimistes ou que tous les pods consomment beaucoup plus que leurs requests en même temps. Le nœud sera alors surchargé.

En cas de surcharge du nœud Kubernetes peut faire recours à l'éviction qui est un mécanisme de défense ( paramétré au niveau du kubelet ). Il s’agit de "descheduler" certains pods par ordre de priorité selon le niveau de QoS, Il s’agit d’une hard limit au niveau du nœud sur la mémoire restante:

(--eviction-hard="memory.available<200Mi")

Dans cet exemple, en cas de surcharge du nœud si la mémoire totale restante est inférieure à 200Mi, l’éviction sera enclenchée et les pods qui ont les QoS les plus basses seront évincés en premier pour libérer les ressources aux pods de QoS plus élevés.

Quotas de ressources

Comme on vient de le voir, les limits et les requests influencent le cycle de vie d’un pod ainsi que la stabilité du nœud. Quand on administre un cluster Kubernetes, on a envie de pouvoir définir des "policies" sur les limits et requests pour s’assurer d’une exploitation optimale et contrôlée du nœud.
Ceci peut se faire à l’aide de RessourceQuota au niveau namespace ainsi qu'avec les LimitRanges au niveau container/pod.

RessourceQuota
Des quotas par namespace sur le total de limits et de requests autorisés. L ’api-server renvoie une erreur "403 Forbidden" si le quota est dépassé.
Si un quota est défini sur limits ou requests pour une ressource donnée (CPU ou mémoire) , alors tout pod est obligé de les définir, et donc par exemple on n’aura plus la possibilité de lancer des pod BestEffort.

Des quotas peuvent être aussi définis sur d’autres entités (nombre de pods, services, secrets, persistentvolumeClaims ..)

apiVersion: v1
kind: ResourceQuota
metadata:
  name: mem-cpu-quota
  namespace: project1
spec:
  hard:
    requests.cpu: "1"
    requests.memory: "3G"
    limits.cpu: "2"
    limits.memory: "5G"

Les RessourceQuota peuvent être aussi spécifiées par scope de pod, on pourrait vouloir par exemple associer un quota qui ne s’applique que sur les pods NotBestEffort ou Terminating etc..

apiVersion: v1
kind: ResourceQuota
metadata:
  name: pods-quota
  namespace: project2
spec:
  hard:
    pods: "3"
  scopes:
  - NotBestEffort

LimitRange
Agit au niveau container ou pod par namespace. Il s’agit d’une bonne pratique pour s’assurer d’avoir toujours des valeurs par défaut pour les limits et requests quand ils ne sont pas définis. Le LimitRange permet aussi d’imposer une borne inférieure sur les requests/pod (ex: ne pas créer des pods trop petits), et une borne supérieure sur les limits/pod pour limiter l'overcommitment.

À noter que :
- SI default non défini ET max défini ALORS default vaut max
- SI defaultRequest non défini ET default défini ALORS defaultRequest vaut default.
- SI defaultRequest non défini ET default non défini ET max défini ALORS defaultRequest et default valent max.
- SI defaultRequest non défini ET default non défini ET max non défini ALORS defaultRequest ET default valent min.

apiVersion: v1
kind: LimitRange
metadata:
  name: mem-defaults
  namespace: project1
spec:
  limits:
  - default:
      memory: "600M"
      cpu: "200m"
    defaultRequest:
      memory: "200M"
      cpu: "200m"
    type: Container

apiVersion: v1
kind: LimitRange
metadata:
  name: mem-min-max
  namespace: project2
spec:
  limits:
  - max:
      memory: "2G"
    min:
      memory: "100M"
    type: Container

Conclusion et Best practices

  • Il faut toujours définir des limits et des requests pour les pods les plus critiques :

    • Quand la limite n’est pas définie, Kubernetes considère que toutes les ressources du nœud peuvent être consommées au runtime.

    • Définir des requests bien évaluées permet au scheduler de prendre une meilleure décision pour placer un pod sur un nœud ou sur un autre.

  • Gérer la stabilité des déploiements et des nœuds en se basant sur les classes de QoS:

    • Éviter la création de pod BestEffort si l’application est sensible aux restarts/kill.
    • Les pods critiques tels que les bases de données et les services statefull doivent être Guaranteed si on veut minimiser la latence CPU et éviter qu’ils soient tués en cas de surcharge de mémoire.
    • Burstable c’est pour les applications les plus communes, mais qu’on veut contrôler leur consommation de ressources en mettant des limits (Un serveur web par exemple).
    • Un pod avec une request basse aura plus de facilité à être schedulé.
  • Utiliser les LimitRange, pour être sûr d’avoir toujours des requests et des limits bien définis, on associant des valeurs par défaut et en imposant des bornes.

  • Protéger le nœud et les processus critiques de Kubernetes, en ajustant le threshold d'éviction.

  • Imposer la définition de limits (et/ou) requests et séparer les environnements en associant RessourceQuotas par namespace (ex : un quota limité sur le namespace de l’environnement DEV) .

  • Monitorer pour bien estimer la consommation de ressources et définir les RessourceQuota et les LimitRange en fonction de cela pour une meilleure exploitation du nœud.