January 2, 2024

Karpenter: le futur de la gestion des workers et de l'autoscaling sur Kubernetes

Contrairement à ce que l’on pourrait penser, je suis rarement "hypé" par des outils. Mais parfois ça arrive, et c’est le cas pour Karpenter qui est en train de révolutionner la gestion des noeuds sur Kubernetes.

Le monde avant Karpenter

Les conteneurs (pods) gérés par Kubernetes tournent sur des workers (généralement des machines virtuelles). Gérer ces workers peut rapidement devenir pénible pour plusieurs raisons.

Sur la majorité des offres Kubernetes dans le cloud (mais aussi sur des implémentations on prem) une abstraction "nodepool" existe, représentant un groupe de machines similaires. On choisit par exemple pour un nodepool les caractéristiques des machines à lancer (nombre de CPU, mémoire, image), la configuration réseau (firewalling, réseau privé), la taille du disque root, et toutes sortes d’options selon le cloud provider (IAM, clés SSH, configuration de cloud init…​). Toutes les instances du Nodepool sont donc identiques.

Une fois un nodepool créé, il est possible de configurer sa taille (et donc le nombre de workers à déployer pour ce nodepool) et de la changer à tout moment (manuellement ou via des outils d’autoscaling).

Le problème qui se pose rapidement est la multiplication du nombre de nodepools à gérer, par exemple:

  • Avoir des nodepools avec des types d’instances (CPU, mémoire, disques, AMI, génération d’instances…​) différents dans le but de faire tourner certains workloads gourmands sur des noeuds dédiés: un nodepool avec des machines 4CPU/16GB de mémoire, un autre 8CPU/32GB…​

  • Avoir des nodepools utilisant des instances de type "spot" (comme par exemple chez AWS): dans ce cas de figure on veut maximiser l’utilisation d’instances spot pour faire baisser la facture cloud tout en démarrant des instances classiques en cas de non disponibilité des spot.

  • Faire du multi availability zone (AZ): cela peut demander la création d’un nodepool par availability zone pour chaque type de nodepool.

On se retrouve donc rapidement avec 3, 6, 10…​ nodepools par cluster et cela devient difficile à gérer: il faut les gérer via de l’infra as code (Terraform par exemple) et donc de manière assez statique et il faut les mettre à jour un par un lors de mise à jour de clusters ou d’AMI.

Faire des économies via un placement intelligent des pods devient également compliqué car tout doit être fait "à la main": je me rappelle par exemple lorsqu’on avait de multiples groupes de machines du type "memory optimized", "high memory optimized" dupliqués par AZ pour faire tourner certaines applications gourmandes: on avait toujours des noeuds sous utilisés.

Gérer le cycle de vie des noeuds peut également être assez pénible:

  • On utilise généralement le cluster autoscaler de Kubernetes pour l’autoscaling des noeuds. Il fonctionne globalement bien mais est assez basique dans son fonctionnement.

  • Lorsqu’un noeud est supprimé (downscaling, mise à jour…​) il faut d’abord drain les pods sur le noeud et seulement ensuite le supprimer. Sur le cloud et notamment lorsqu’on utilise des instances spot on veut aussi parfois préventivement supprimer un noeud qui risque d’être réclamé par le cloud provider dans un futur proche. Sur AWS cela se fait via une gestion d’événements poussés dans une queue SQS et l’utilisation d’un composant supplémentaire à déployer (AWS node termination handler). AIlleurs, c’est souvent à vous de construire l’outillage pour le faire.

Karpenter

Karpenter est un outil open source développé par AWS pour répondre à la problématique de gestion des workers dans Kubernetes. L’outil s’est longtemps limité à la gestion de noeuds sur AWS, mais son architecture modulaire (une partie "core" et une "plugin" pour les intégrations spécifiques à chaque cloud provider) et la qualité de l’outil font qu’il est en train de prendre de l’ampleur. L’outil autrefois géré sur le compte github d’AWS a maintenant rejoint la communauté Kubernetes (sigs autoscaling), et Microsoft Azure vient également de le rendre disponible sur son cloud.

Pour moi, Karpenter est le futur de la gestion de noeuds sur Kubernetes, et va dans les prochaines années complètement remplacer cluster-autoscaler. Je vais expliquer pourquoi en expliquant rapidement comment fonctionne Karpenter sur AWS.

En pratique

Karpenter est un operator Kubernetes et doit donc s’installer sur le cluster (via helm par exemple), ou du moins communiquer avec l’apiserver pour pouvoir gérer les noeuds. AWS sortira d’ailleurs Karpenter en tant qu’Addons complètement géré par AWS dans les mois à venir.
Il se configure ensuite via des CRD spécifiques comme n’importe quel operator Kubernetes.

Sur AWS, on utilisera les ressources EC2NodeClass (spécifiques à AWS) et NodePool (générique). Je présume que sur Azure EC2NodeClass serait remplacé par une autre CRD, et que dans le futur chaque cloud (ou outil déployant du Kubernetes on prem avec une intégration Karpenter) aura sa CRD dédiée.

EC2NodeClass

Voici un exemple d’EC2NodeClass appelé example:

apiVersion: karpenter.k8s.aws/v1beta1
kind: EC2NodeClass
metadata:
  name: example
spec:
  amiFamily: AL2
  blockDeviceMappings:
  - deviceName: /dev/xvda
    ebs:
      encrypted: true
      volumeSize: 70Gi
      volumeType: gp3
  metadataOptions:
    httpEndpoint: enabled
    httpPutResponseHopLimit: 1
    httpTokens: required
  role: karpenter-example-iam-role
  securityGroupSelectorTerms:
  - id: sg-1cb731bc101ab1e3f
  - id: sg-2a3fa43a111ab1a54
  subnetSelectorTerms:
  - tags:
      Name: kubernetes-*
  tags:
    Name: example-karpenter
    environment: production
    managed-by: karpenter
  userData: |
    #!/bin/bash
    echo "optional cloud init user data"

Cette ressource représente la configuration AWS d’un worker:

  • l’AMI à utiliser: ici, on se contente de spécifier la famille d’AMI à utiliser (AL2, Amazon Linux 2 ici). Karpenter prendra automatiquement l’AMI la plus récente pour la version du cluster Kubernetes cible, mais il est également possible de spécifier l’image exacte à configurer.

  • La configuration du disque racine (taille, chiffrement, storage class…​).

  • Des options liées au serveur de metadata de l’instance.

  • Le role à attacher à l’instance (sur AWS on configure généralement très finement les permissions de chaque machine notamment sur EKS).

  • Les security groups (règles de firewalling) à attacher à l’instance. Il est possible de choisir ces security groups par ID (ce que je fais dans cet exemple), par nom, par tags (et donc d’inclure tout ceux avec un tag donné), ou une combinaison de tout ça.

  • Les subnets de réseaux privés (VPC) à utiliser pour la machine (là aussi la sélection peut se faire via différents critères).

  • Des tags à attacher aux machines.

  • Des user data pour avoir si besoin une configuration cloud init spécifique.

Comme dit précédemment tout ceci est assez spécifique à AWS mais on retrouvera des choses similaires dans d’autres implémentations futures de Karpenter sur l’autres plateformes.

En conclusion, vous définissez ici à quoi doit ressembler la partie "infra" de vos noeuds via un simple fichier YAML.

Voyons maintenant la partie NodePool.

NodePool

La CRD NodePool représente un groupe de noeuds et permet de lier une NodeClass avec une gestion de cycle de vie des noeuds. Si le scheduler de Kubernetes n’arrive pas à éployer un pod par manque de capacité, Karpenter sélectionnera un NodePool qui par sa configuration pourrait accueillir le pod et et créera un nouveau noeud.
Karpenter s’occupera également de tout l’aspect autoscaling (donc cluster autoscaler n’est plus nécessaire) et comme nous allons le voir, de bien plus.

Voici un exemple de NodePool:

apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
  name: example
spec:
  disruption:
    consolidationPolicy: WhenUnderutilized
    expireAfter: 24h0m0s
  limits:
    cpu: "400"
  template:
    metadata:
      labels:
        purpose: example
    spec:
      nodeClassRef:
        name: example
      requirements:
      - key: karpenter.k8s.aws/instance-category
        operator: In
        values:
        - c
        - m
        - r
      - key: kubernetes.io/arch
        operator: In
        values:
        - amd64
      - key: karpenter.sh/capacity-type
        operator: In
        values:
        - spot
        - on-demand
      - key: kubernetes.io/os
        operator: In
        values:
        - linux

La première chose très intéressante dans la configuration d’un NodePool est la partie disruption. Ici, la partie consolidationPolicy indique à Karpenter d’optimiser continuellement le placement des pods sur le cluster pour utiliser le moins de ressources (et donc de noeuds) possibles. Rien que ça est génial et permet de faire baisser de manière non négligeable votre facture cloud, et d’avoir un taux d’occupation de vos noeuds Kubernetes très important !
Encore mieux, Karpenter fera également en sorte d’utiliser les noeuds disponibles sur le cloud les moins chers, mais j’en reparlerai juste après.

L’option expireAfter permet d’ajouter un TTL sur les noeuds. Ici, les noeuds seront automatiquement recyclés (supprimés et recréés) toutes les 24 heures. Cela apporte de nombreux avantages:

  • Les noeuds se mettent à jour tout seul, que ce soit en cas de nouvelle version de l’image (AMI) référencée par la NodeClass, ou en cas de mise à jour du cluster Kubernetes à une nouvelle version.

  • On a une vraie approche "infrastructure immuable" sur la gestion des noeuds, qui sont vraiment des ressources jetables et remplaçables à tout moment.

  • Cela force tous les utilisateurs du cluster Kubernetes (dev comme ops) à construire des programmes et des architectures pouvant supporter un recyclage permanent des noeuds.
    Lorsque l’on fait un recyclage des noeuds que tous les 3 mois par exemple, on a tendance à ne pas voir certains problèmes applicatifs sous-jacents: applications tolérant mal le drain de ses pods, problèmes réseaux transitoires causant de la perte de traffic…​ Tout péter toutes les 24H met très rapidement en évidence des problèmes et donc force les gens à les corriger, ce qui améliore grandement la résilience des applications de manière générale. D’ailleurs ce pattern est clairement génial, qu’on soit sur Kubernetes ou non.

La partie limits de la spec permet de mettre une limite sur la capacité totale du NodePool. Ici, la somme des CPU des machines gérées par ce NodePool ne pourra pas dépasser 400. C’est intéressant pour éviter de scale à l’infini un NodePool.

La configuration template.metadata contiendra des labels (et annotations si besoin) à propager aux noeuds créés.

Enfin, la partie template.spec.requirements permet de définir des conditions pour les noeuds qui seront provisionnés. Dans cet exemple, que des machines des classes d’instances c, m, et r d’AWS (via karpenter.k8s.aws/instance-category), des instances amd64 (via kubernetes.io/arch), des instances spot ou on demand (via karpenter.sh/capacity-type) et enfin des instance Linux (kubernetes.io/os) pourront être démarrées par ce NodePool.

Il existe de nombreux autres requirements possibles qui sont listés dans la documentation de Karpenter. On peut par exemple ajouter une taille maximum (en terme de CPU/mémoire) ou minimum pour les noeuds.

Cette CRD permet également de configurer des taints sur les noeuds gérés par un NodePool, ainsi que la configuration de Kubelet.

J’avais expliqué précédemment que Karpenter essaye d’optimiser le déploiement des noeuds pour que cela coûte le moins cher possible via la consolidation. Dans cet exemple, Karpenter essayerait par exemple de démarrer en priorité des instances "spot", ou bien tentera en permanence de créer des workers optimisés pour les workloads.

La chose importante à garder en tête est les noeuds d’un NodePool Karpenter peuvent avoir différentes tailles (ce qui n’est généralement pas le cas sans Karpenter). Si Karpenter détecte que le plus rentable en terme de prix (facture cloud) est d’avoir un noeud avec 64 GB de mémoire et 16 CPU et 3 noeuds de 32 GB de mémoire et 8 CPU pour les pods tournant sur le cluster, le tout faisant partie d’une certaine catégorie d’instances, c’est ce qu’il déploiera.
Au bout d’un moment, peut être qu’il remplacera les 3 noeuds par 2 noeuds plus gros, en spot par exemple, car moins cher. L’infrastructure est donc très dynamique car en permanence modifiée pour optimiser la facture.

Vous n’avez donc pas à créer beaucoup de Nodepool Karpenter comme c’était le cas avant pour les Nodepool "non Karpenter" (où on créeait un NodePool par taille de macine à déployer sur le cluster). On se contente de laisser Karpenter créer les meilleures instances (bonne taille, coût le moins cher…​) pour nos workloads, et des pods très disparates en terme d’utilisation de ressources cohabiterons sans problème sur le même NodePool.

Drift

Une fonctionnalité intéressante des NodePool Karpenter est également la gestion du drift, qui se configure dans la partie disruption de la CRD.
En cas de changement de configuration d’un NodePool ou NodeClass, si les workers actuels (ou un subset d’instances) ne sont plus valides en terme de configuration, Karpenter les recréera automatiquement car il détectera que l’instance n’est plus valide.
Après, si vous mettez expireAfter à 24 heures comme dans cet exemple, juste attendre peut être suffisant: après un jour toutes les instances seront forcément mises à jour.

On peut également tout simplement supprimer un noeud de manière sûre via kubectl delete node grâe à Karpenter: ce dernier s’occupera de drain le noeud et d’en démarrer un nouveau si nécessaire.

Précautions à prendre

Comme dit précédemment, Karpenter passera son temps à supprimer et recréer des noeuds (TTL sur les noeuds, consolidation, drift…​). Il faut donc que les applications tolèrent ça mais c’est de toute façon obligatoire d’avoir des applications "cloud native" sur Kubernetes, ou même ailleurs.

Il faudra également configurer correctement des PodDisruptionBudget sur vos pods. Sans cela, vous n’aurez pas de garantie sur le nombre minimum de replica "up" pour par exemple un déploiement.
Karpenter comprend nativement toutes les primitives de Kubernetes (PDB, affinity et anti affinity, Topology Spread Constraints…​).

Enfin, ajouter l’annotation karpenter.sh/do-not-disrupt: "true" sur un pod indiquera à Karpenter de ne jamais supprimer le noeud où tourne le pod. Il ne faut bien sûr pas ajouter cela sur un pod tournant en continu (type déploiement), sinon le noeud ne pourra jamais se mettre à jour. Mais elle est très utile sur les pods générés par des Jobs (et donc aussi CronJob): cela permettra à Karpenter d’attendre la fin d’un job avant de supprimer un noeud, et de ne pas l’interrompre en plein milieu.

Conclusion

Il y a eu pour moi un avant et un après Karpenter dans mon utilisation de Kubernetes. C’est comme dans le cochon, tout est bon dans le produit. Je pense qu’il va vraiment prendre de l’ampleur dans la communauté Kubernetes ces prochaines années et qu’avoir une intégration Karpenter sera vraiment un différentiateur entre offres Kubernetes, notamment sur le cloud mais pas que: pourquoi ne pas écrire des plugins Karpenter pour l’on prem également ?

Tags: devops

Add a comment








If you have a bug/issue with the commenting system, please send me an email (my email is in the "About" section).

Top of page