KubernetesCoûts CloudAWSAvancéFinOps

FinOps Kubernetes sur EKS avec Karpenter

Jerry RajaonarivoMis à jour le 30 sept. 202610 min de lecture

Cet article fait suite, côté EKS, au travail de rightsizing. On y remplace les node groups statiques et le réglage du Cluster Autoscaler par le provisioning de nodes de Karpenter, juste-à-temps et par bin-packing. Chaque pod obtient ainsi une forme d'instance adaptée, au lieu que tout soit casé dans une poignée de formes prédéfinies. Le guide d'optimisation des coûts est centré sur GKE, mais son volet rightsizing s'applique à n'importe quel cluster. Si ce travail n'est pas encore fait chez vous, commencez par là.

Ce que Karpenter fait différemment

Karpenter supprime complètement l'abstraction de node group. Il surveille les pods unschedulable, regarde ce dont ils ont besoin, et choisit parmi tous les instance types éligibles les moins chers qui satisfont ces contraintes, puis appelle directement l'API EC2 Fleet. Il n'y a ni ASG ni node group par forme.

Un pod en attente qui demande 400m de CPU et 512Mi de mémoire reçoit une petite instance ; un pod qui demande 8 vCPU et 32 Go en reçoit une tout autre : dans le même NodePool, en quelques secondes, parce que Karpenter résout un problème de bin-packing contre les prix et la capacité EC2 en temps réel.

Le spot devient aussi le comportement par défaut, au lieu d'un second node group à construire et à surveiller à la main. Un seul NodePool peut exprimer « préférer le spot, retomber sur l'on-demand », et Karpenter se charge de la diversification d'instance types qui rend le spot fiable. Les équipes sous Cluster Autoscaler avec des ASG spot gérés à la main font souvent l'impasse sur cette diversification. Elles s'en tiennent à deux ou trois instance types et subissent bien plus d'interruptions que nécessaire.

Installer Karpenter avec la gestion des interruptions

Karpenter ne gère les interruptions spot que si vous lui fournissez une file SQS alimentée par des règles EventBridge et que vous lui donnez le nom de cette file. Rien ne l'active implicitement. Si settings.interruptionQueue est vide, la gestion des interruptions est désactivée. Le sous-module karpenter de terraform-aws-modules/eks crée d'un coup la file, les règles EventBridge, le rôle IAM du contrôleur (via EKS Pod Identity) et le rôle IAM des nodes :

hcl
module "karpenter" {
  source  = "terraform-aws-modules/eks/aws//modules/karpenter"
  version = "~> 21.0"
 
  cluster_name = module.eks.cluster_name
 
  # file SQS + règles EventBridge pour les interruptions spot, les maintenances
  # planifiées et les changements d'état d'instance
  enable_spot_termination = true
 
  # nom fixe pour que l'EC2NodeClass ci-dessous puisse le référencer
  node_iam_role_use_name_prefix = false
  node_iam_role_name            = "karpenter-node-role"
}
 
resource "helm_release" "karpenter" {
  name       = "karpenter"
  namespace  = "kube-system"
  repository = "oci://public.ecr.aws/karpenter"
  chart      = "karpenter"
  version    = "1.14.1" # épinglé ; vérifiez la matrice de compatibilité Karpenter pour votre version d'EKS
 
  values = [yamlencode({
    settings = {
      clusterName       = module.eks.cluster_name
      interruptionQueue = module.karpenter.queue_name
    }
  })]
}

L'association Pod Identity du module cible par défaut le service account karpenter dans kube-system, celui que crée le chart ici. Le cluster doit avoir l'add-on EKS Pod Identity Agent installé. Si vous n'utilisez pas Terraform, le guide de démarrage de Karpenter fournit un template CloudFormation qui crée la même file et les mêmes règles.

Le NodePool et l'EC2NodeClass

Deux CRDs font le travail. EC2NodeClass décrit les détails spécifiques à AWS : AMI, subnets, security groups, rôle IAM. NodePool décrit les contraintes de scheduling et le comportement de disruption.

yaml
apiVersion: karpenter.k8s.aws/v1
kind: EC2NodeClass
metadata:
  name: default
spec:
  amiSelectorTerms:
    - alias: al2023@latest # épinglez une version précise (al2023@vYYYYMMDD) en production
  role: "karpenter-node-role"
  subnetSelectorTerms:
    - tags:
        karpenter.sh/discovery: "prod-cluster"
  securityGroupSelectorTerms:
    - tags:
        karpenter.sh/discovery: "prod-cluster"
  blockDeviceMappings:
    - deviceName: /dev/xvda
      ebs:
        volumeSize: 50Gi
        volumeType: gp3
        deleteOnTermination: true

amiSelectorTerms est obligatoire dans Karpenter v1, et l'alias implique aussi la famille d'AMI, d'où l'absence de amiFamily séparé. @latest est pratique pour un premier essai, mais chaque nouvelle AMI se propage alors dans votre flotte par drift, à son propre rythme. En production, épinglez une version précise et faites-la évoluer vous-même, via le même workflow Git que le reste de la configuration.

yaml
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
  name: general-purpose
spec:
  template:
    spec:
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64"]
        - key: karpenter.k8s.aws/instance-category
          operator: In
          values: ["c", "m", "r"]
        - key: karpenter.k8s.aws/instance-generation
          operator: Gt
          values: ["4"]
      nodeClassRef:
        group: karpenter.k8s.aws
        kind: EC2NodeClass
        name: default
      expireAfter: 336h
      terminationGracePeriod: 24h
  limits:
    cpu: 1000
    memory: 1000Gi
  disruption:
    consolidationPolicy: WhenEmptyOrUnderutilized
    consolidateAfter: 1m

Quand un NodePool autorise à la fois spot et on-demand, Karpenter donne la priorité au spot (il ne compare pas les deux sur le prix). Il retombe automatiquement sur l'on-demand lorsqu'aucune offre spot adaptée au pod n'est disponible, au lancement comme lors du remplacement d'un node interrompu. expireAfter: 336h remplace les nodes toutes les deux semaines même si rien d'autre ne le déclenche. Cela limite le temps pendant lequel un node peut s'écarter de l'AMI à jour sans que personne ne le remarque, et impose un re-bin-packing régulier. terminationGracePeriod: 24h plafonne la durée du drain de ces nodes. J'explique pourquoi plus bas.

Les charges de travail n'ont pas besoin de nodeSelector pour atterrir sur ce pool. Un nodeSelector: karpenter.sh/capacity-type: spot strict supprime le repli sur l'on-demand, et quand le spot est indisponible, les pods restent en Pending. Pour préférer le spot au niveau du workload, utilisez une node affinity préférentielle. Répartissez aussi les replicas sur plusieurs zones pour qu'une vague de récupération spot ne puisse pas toutes les emporter :

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout-api
spec:
  replicas: 6
  selector:
    matchLabels:
      app: checkout-api
  template:
    metadata:
      labels:
        app: checkout-api
    spec:
      affinity:
        nodeAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              preference:
                matchExpressions:
                  - key: karpenter.sh/capacity-type
                    operator: In
                    values: ["spot"]
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: checkout-api
      containers:
        - name: checkout-api
          image: registry.internal/checkout-api:1.4.2
          resources:
            requests:
              cpu: "500m"
              memory: "1Gi"
            limits:
              memory: "1Gi"

Le labelSelector de la contrainte de répartition ne fonctionne que parce que le template de pod porte app: checkout-api. Sans labels sur le template (et le spec.selector correspondant), l'API server rejette purement et simplement le Deployment. Pour les ressources, on reprend le schéma du reste de cette série, avec des requests CPU, pas de limite CPU, et une request et une limite mémoire.

La consolidation : ce que le Cluster Autoscaler n'a jamais bien fait

Le Cluster Autoscaler retire bien les nodes sous-utilisés. Quand les ressources demandées sur un node passent sous son seuil d'utilisation et que ses pods tiennent ailleurs, il draine le node et réduit le node group. En revanche, il ne peut pas changer la forme de la flotte. Il n'ajoute ou ne retire que des nodes des instance types avec lesquels chaque node group a été construit, et ne remplacera donc jamais trois m5.2xlarge à moitié vides par une seule instance bien dimensionnée et moins chère. La boucle de consolidation de Karpenter s'en charge en continu. Elle simule si les pods en cours pourraient tenir sur moins de nodes ou sur des nodes moins chers, et si oui lance l'éventuel remplaçant, puis cordonne, draine et termine l'excédent, et le scheduler re-packe le reste sur une empreinte plus resserrée. La policy WhenEmpty seule ne retire que les nodes vides. consolidationPolicy: WhenEmptyOrUnderutilized cible aussi les nodes sous-utilisés, si bien que la flotte redescend après un pic de trafic au lieu de rester gonflée.

Ce que WhenEmptyOrUnderutilized fait gagner par rapport à WhenEmpty dépend du degré de fragmentation de votre flotte, alors mesurez-le vous-même plutôt que de vous fier au chiffre avancé par quelqu'un d'autre. Relevez le nombre de nodes et le coût compute pendant une semaine, changez la policy, et comparez avec la semaine suivante.

La consolidation est disruptive par construction. Tout ce qui ne tolère pas un reschedule imprévu doit le déclarer explicitement, sur le pod lui-même. Pour un job batch, cela se fait dans le template de pod d'un Job :

yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: batch-report
spec:
  backoffLimit: 2
  template:
    metadata:
      annotations:
        karpenter.sh/do-not-disrupt: "true"
    spec:
      restartPolicy: Never
      nodeSelector:
        karpenter.sh/capacity-type: on-demand # les interruptions spot ignorent do-not-disrupt
      containers:
        - name: report
          image: registry.internal/report-generator:2.1.0
          resources:
            requests:
              cpu: "1"
              memory: "2Gi"
            limits:
              memory: "2Gi"

Le nodeSelector strict est voulu ici. Ce job ne survit pas à une interruption, il n'a donc rien à faire en spot.

karpenter.sh/do-not-disrupt bloque la disruption volontaire (consolidation et drift) sur le node qui héberge ce pod. Il ne bloque pas les méthodes forcées, donc l'expiration, l'interruption spot, la réparation de node et la suppression manuelle ont lieu quand même. Quand un node expiré est drainé, Karpenter n'évince pas le pod annoté. Le node est tainté et attend la fin du pod ou l'écoulement du terminationGracePeriod du NodePool, après quoi le pod est supprimé malgré tout. Sans terminationGracePeriod, un pod annoté de longue durée peut laisser un node à moitié drainé bloqué dans le cluster. L'annotation a du sens pour un job en plein checkpoint. Je l'ai aussi vue recopiée sur des Deployments de longue durée, où elle bloquait la consolidation de nodes par ailleurs inoccupés. Quelques annotations oubliées peuvent annuler une bonne partie des gains de consolidation.

La gestion des interruptions spot, nativement

L'ancien pattern, Cluster Autoscaler plus des ASG spot gérés à la main, s'appuyait sur aws-node-termination-handler. Il tourne en DaemonSet, interroge l'instance metadata service pour détecter le préavis d'interruption de deux minutes, puis cordonne et draine le node. Ça fonctionne, mais c'est un composant de plus à déployer, mettre à jour et surveiller, et il ne provisionne pas de capacité de remplacement. Celle-ci n'arrive qu'une fois les pods passés en Pending, au prochain passage du Cluster Autoscaler.

Avec la file configurée plus haut, Karpenter lit les mêmes signaux depuis SQS. Cela comprend le préavis d'interruption spot de deux minutes, ainsi que les maintenances planifiées, les arrêts/terminaisons d'instance et les échecs de status check. Sur un préavis d'interruption spot, il tainte et draine le node et lance tout de suite le provisioning du remplaçant, dans la fenêtre de deux minutes.

Ce que cela donne dans les chiffres FinOps

Le rightsizing et le changement de modèle de provisioning se renforcent mutuellement. Des requests réalistes donnent à Karpenter des entrées de bin-packing précises, et Karpenter en tire une vraie baisse du nombre de nodes au lieu de laisser du slack dans des groupes statiques surdimensionnés. Si le rightsizing des requests est déjà fait mais que le cluster tourne encore sous Cluster Autoscaler avec deux ou trois node groups statiques, le passage à Karpenter retire généralement une part supplémentaire notable de la ligne compute, surtout grâce à la consolidation et à une meilleure diversification spot. L'ampleur de ce gain dépend de l'inadéquation des groupes statiques à vos workloads. Mesurez-le avec Cost Explorer ou vos données CUR avant et après, plutôt que de budgéter un chiffre à l'avance.

Adopter tout ça ne touche à aucun dépôt applicatif. C'est un changement de plan de contrôle : un nouveau composant d'autoscaling, des définitions NodePool/EC2NodeClass, des PodDisruptionBudgets qui veulent vraiment dire ce qu'ils disent, et la consolidation qu'on laisse tourner. Le vrai coût apparaît après, côté ownership : quelqu'un maintient désormais la config NodePool comme on possédait autrefois des ASG, et quelqu'un audite l'usage de do-not-disrupt avant que ça ne devienne une planque pour de la capacité inoccupée.

ALLER PLUS LOIN

Envie de faire tourner ça en production ?

Ce tutoriel couvre les concepts et l'architecture. Si vous voulez l'implémenter dans votre propre infrastructure, ou monter en compétence pour posséder ce sujet durablement, je propose du mentorat individuel construit autour de votre environnement réel, pas une formation générique.

Ce tutoriel

  • Concepts clés et architecture principale
  • Extraits de code illustratifs
  • Le raisonnement derrière chaque décision

Mentorat individuel

  • Des sessions de travail sur votre propre environnement
  • Des réponses directes aux cas particuliers que vous rencontrez
  • Un retour sur votre implémentation réelle
  • Un accompagnement continu pendant que vous la construisez
Me contacterRéponse sous 1 jour ouvré.

Tutoriels similaires