Karpenter + Cluster API
Dans les grands avantages de Kubernetes, il y a le fait de pouvoir gérer de manière élastique nos ressources. Si un pod stateless consomme trop, on peut lui rajouter des réplicas, s’il possède une consommation irrégulière, collons-lui un HPA (Horizontal Pod Autoscaler) et il s’adaptera à la charge. Notre cluster est plein ? On peut lui rajouter des nodes.
Mais si l’autoscaling des réplicas est automatique, ça n’est pas forcément trivial de faire de même pour les nodes.
Dans mon article sur la Cluster API, je terminais en lâchant une petite ouverture :
J’ai encore d’autres choses à tester avec la CAPI, notamment l’intégration d’un programme gérant l’auto-scaling (ou même karpenter) […]
Avec quelques mois de retard, certes, on s’attaque à l’autoscaling d’un cluster Kubernetes déployé via la Cluster API.
Mais pour réaliser cette étape d’autoscaling des nodes, deux approches sont possibles :
- Cluster Autoscaler, la solution conçue pour fonctionner à 100% avec CAPI.
- Karpenter, une solution qu’AWS a donné à la CNCF et qui possède une intégration CAPI également.
Quelles solutions pour l’autoscaling
Les deux savent ajouter (et retirer) des nodes, mais avec deux philosophies opposées. Comparons-les avant d’aller plus loin dans le PoC.
Le Cluster Autoscaler raisonne en groupes de machines : vous lui donnez des node groups (des MachineDeployments, je vous invite à lire mon article qui détaille ce concept), et il joue sur le nombre de réplicas. Si un pod ne rentre nulle part, il incrémente le réplica d’un groupe compatible et tada : un nouveau noeud rejoint le cluster.
Karpenter, lui, raisonne par groupe de pods. Il regarde les pods en Pending, calcule la machine la plus adaptée (et la moins chère) pour les accueillir, et la provisionne directement. Pas de node group à dimensionner à la main : on décrit des contraintes (NodePool) et Karpenter se débrouille en trouvant la machine la plus adaptée (même des instances Spot qui n’existent que pendant un certain temps et sont très peu chères).
À savoir que Karpenter est historiquement très lié à AWS, mais depuis sa donation à la CNCF, le cœur du projet (sigs.k8s.io/karpenter) est devenu agnostique, et des providers tiers sont apparus. Dont un qui m’intéresse tout particulièrement : karpenter-provider-cluster-api puisqu’il va permettre d’être la glue entre Karpenter et les Cloud-providers compatibles CAPI (L’usage de CAPI permet de couvrir un maximum de providers, dont OVH, mais d’autres Cloud développent eux aussi leurs propres providers Karpenter pour les offres managées).
Une nuance importante avant d’aller plus loin : nativement (sur AWS, Azure, GCP), Karpenter tourne en standalone. Il vit dans le cluster qu’il scale et parle directement à l’API du cloud sans cluster tiers : c’est même tout son intérêt, aucun cluster de management. Mais ici, en passant par le provider cluster-api, Karpenter ne pilote plus un cloud, il pilote des ressources CAPI, qui doivent bien vivre quelque part. On réintroduit donc un cluster de management que le fonctionnement natif de Karpenter n’exige pas. On parlera plus bas de ce que ça implique.
Comment fonctionne Karpenter ?
Détaillons d’abord comment Karpenter fonctionne dans un Kubernetes, et les ressources (CRDs) qu’il expose :
NodePool: c’est les règles. Il décrit ce que Karpenter a le droit de créer, sous forme derequirements(architecture,capacity-type, zone, types d’instances autorisés…), delimitsglobales (par ex. “pas plus de 20 vCPU au total”), et d’une politique dedown-scaling. Il pointe vers une NodeClass.NodeClass: c’est la partie spécifique au provider. Sur AWS, uneEC2NodeClassdécrit l’AMI, les subnets, les security groups, le profil IAM… Ici, comme on délègue tout à la Cluster API, nous avons unClusterAPINodeClass.NodeClaim: c’est la demande concrète d’une machine. Quand Karpenter décide qu’il faut un node, il ne crée pas une VM directement : il crée unNodeClaim, un objet déclaratif qui correspond à la requête qu’il va faire au provider. C’est ensuite au provider de le matérialiser (sur AWS = une instance EC2 ; chez nous, c’est un scale-up d’unMachineDeploymentappartenant à CAPI).
Si on devait résumer le cycle de vie d’un node Karpenter, ça donnerait ça :
- Le scheduler de Karpenter observe les pods non schedulables (en
Pendingpar manque de ressource/node) ; - Il les regroupe par lots sur une courte fenêtre, pour éviter de créer une machine par pod ;
- Pour chaque lot, il simule le scheduling comme le ferait Kubernetes (en prenant en compte les
requestsCPU/mémoire, les affinités, les taints/tolerations, lestopologySpreadConstraints) pour en déduire la flavor de node la plus adaptée : combien de CPU, de RAM, quelle architecture… ; - Parmi les flavors compatibles avec le
NodePool, il choisit la machine la moins chère qui fait le job, et crée unNodeClaim; - le provider transforme ce
NodeClaimen machine réelle (e.g. une instance EC2 sur AWS, ou uneMachineCAPI dans notre cas) ;
Là où le Cluster Autoscaler se contente d’incrémenter le réplica d’un groupe pré-défini et d’itérer, Karpenter part des besoins réels pour choisir la machine. Il peut donc piocher dans des types de machines très différents selon la charge, par exemple des nodes avec GPU quand un pod en réclame.
Le scale-down
Si on ne fait que rajouter des nodes sans en retirer, ça ne sert pas à grand chose.
La force de Karpenter, c’est sa gestion active du scale-down, qu’il appelle la disruption. Elle est déclenchée par plusieurs mécanismes :
- Consolidation : si des nodes sont vides ou sous-utilisés, Karpenter déplace les pods et supprime les machines en trop. C’est le mécanisme de scale-down (et de scale-to-zero). Deux politiques :
WhenEmpty(on ne supprime que les nodes complètement vides) etWhenEmptyOrUnderutilized(il supprime les nodes sous-utilisés) ; - Drift : si la configuration réelle d’un node ne correspond plus à ce que décrivent son
NodePool/NodeClass, Karpenter considère que le node n’est pas dans l’état souhaité et va le recréer. - Expiration (
expireAfter) : on force le renouvellement des nodes après une durée donnée (rotation, patching d’image) ;
Créer notre cluster de management
Pour utiliser ClusterAPI, nous avons besoin d’un cluster “parent” qui va déployer des clusters “enfants”. N’importe quel cluster suffirait pour peu qu’il ait accès à l’API-Server, et aux nodes (par exemple pour envoyer une configuration via SSH).
Je passe par le Kubernetes Managé d’OVH (MKS) pour ce cluster de management qui apporte beaucoup de facilité dans le PoC. OpenTofu rendra ce setup facile à détruire / reconstruire (et vu que j’écris jamais mes articles en one-shot, ça me permet d’économiser des crédits).
resource "ovh_cloud_project_kube" "mgmt" {
service_name = var.service_name # = OS_PROJECT_ID
name = "capi-mgmt"
region = "GRA9"
update_policy = "MINIMAL_DOWNTIME"
}
resource "ovh_cloud_project_kube_nodepool" "mgmt" {
service_name = var.service_name
kube_id = ovh_cloud_project_kube.mgmt.id
name = "default"
flavor_name = "b3-8" # 2 vCPU / 8 Go
desired_nodes = 2
min_nodes = 2
max_nodes = 2
}
Le cluster MKS me sert juste de support pour les contrôleurs CAPI. Rien n’oblige par contre à ce que le workload cluster (celui que CAPO, le Cluster Api Provider Openstack, va gérer) soit dans la même région. Une fois le MKS prêt et le kubeconfig récupéré, on installe les providers CAPI dessus :
tofu output -raw kubeconfig > kubeconfig
export KUBECONFIG=$PWD/kubeconfig
kubectl apply --server-side \
-f https://github.com/k-orc/openstack-resource-controller/releases/download/v2.6.0/install.yaml
clusterctl init --infrastructure openstack
Avant le clusterctl init, il faut installer la dépendance ORC (OpenStack Resource Controller) qui expose les ressources OpenStack comme des objets Kubernetes réconciliés. C’est devenu une dépendance obligatoire de CAPO : si on ne l’installe pas avant, le contrôleur crashe en boucle avec no matches for kind "Image" in version "openstack.k-orc.cloud/v1alpha1". D’où le kubectl apply d’ORC juste avant le clusterctl init.
Déployer le cluster enfant
Avoir un cluster de management est une chose, mais c’est pas lui qui va être autoscalé, on a besoin du cluster enfant qui sera déployé entièrement via CAPO (et non pas du HCL, c’est nettement plus confortable 😎).
apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
kind: OpenStackCluster
metadata:
name: capi-ovh
spec:
identityRef:
cloudName: ovh
name: capi-ovh-cloud-config
apiServerLoadBalancer:
enabled: true
externalNetwork:
id: 6d041167-5863-4cad-a165-d352bb6720ab # Ext-Net EU-WEST-PAR
managedSecurityGroups: {}
managedSubnets:
- cidr: 10.6.0.0/24
dnsNameservers:
- 1.1.1.1
---
apiVersion: cluster.x-k8s.io/v1beta2
kind: Cluster
metadata:
name: capi-ovh
spec:
clusterNetwork:
pods:
cidrBlocks: ["192.168.0.0/16"]
services:
cidrBlocks: ["10.96.0.0/12"]
controlPlaneRef:
apiGroup: controlplane.cluster.x-k8s.io
kind: KubeadmControlPlane
name: capi-ovh-cp
infrastructureRef:
apiGroup: infrastructure.cluster.x-k8s.io
kind: OpenStackCluster
name: capi-ovh
---
apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
kind: OpenStackMachineTemplate
metadata:
name: capi-ovh-cp
spec:
template:
spec:
flavor: c3-4
image:
filter:
name: ubuntu-2404-noble
identityRef:
cloudName: ovh
name: capi-ovh-cloud-config
configDrive: true
---
apiVersion: controlplane.cluster.x-k8s.io/v1beta2
kind: KubeadmControlPlane
metadata:
name: capi-ovh-cp
spec:
version: v1.36.1
replicas: 3
machineTemplate:
spec:
infrastructureRef:
apiGroup: infrastructure.cluster.x-k8s.io
kind: OpenStackMachineTemplate
name: capi-ovh-cp
kubeadmConfigSpec:
clusterConfiguration:
controllerManager:
extraArgs:
- { name: cloud-provider, value: external }
initConfiguration:
nodeRegistration:
kubeletExtraArgs:
- { name: cloud-provider, value: external }
name: '{{ local_hostname }}'
---
apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
kind: OpenStackMachineTemplate
metadata:
name: capi-ovh-md-0
spec:
template:
spec:
flavor: c3-4
image:
filter:
name: ubuntu-2404-noble
identityRef:
cloudName: ovh
name: capi-ovh-cloud-config
configDrive: true
---
apiVersion: bootstrap.cluster.x-k8s.io/v1beta2
kind: KubeadmConfigTemplate
metadata:
name: capi-ovh-md-0
spec:
template:
spec:
joinConfiguration:
nodeRegistration:
kubeletExtraArgs:
- { name: cloud-provider, value: external }
name: '{{ local_hostname }}'
---
apiVersion: cluster.x-k8s.io/v1beta2
kind: MachineDeployment
metadata:
name: capi-ovh-md-0
spec:
clusterName: capi-ovh
replicas: 1
selector:
matchLabels: null
template:
spec:
clusterName: capi-ovh
version: v1.36.1
bootstrap:
configRef:
apiGroup: bootstrap.cluster.x-k8s.io
kind: KubeadmConfigTemplate
name: capi-ovh-md-0
infrastructureRef:
apiGroup: infrastructure.cluster.x-k8s.io
kind: OpenStackMachineTemplate
name: capi-ovh-md-0
Concernant les nodes du cluster enfant, je vais utiliser la cloud image Ubuntu officielle. Rester sur une image bare est plus simple à reproduire et à tester, en revanche c’est un peu plus long au démarrage que les images officielles puisqu’on doit installer plein de dépendances. Dans un vrai contexte de production, je serais passé par le image-builder officiel.
et désolé à ceux qui voulaient voir du Talos, ça compliquait trop la démo, je suis resté sur un Ubuntu classique pour le PoC.
Avant d’appliquer ce manifest, il faudra quand même donner les identifiants à CAPO, c’est exactement ce que pointe l’identityRef du manifeste (name: capi-ovh-cloud-config). Le clouds.yaml se récupère depuis l’interface Horizon (directement sur l’openstack OVH).
kubectl -n capi-ovh create secret generic capi-ovh-cloud-config \
--from-file=clouds.yaml=clouds.yaml
Quelques minutes plus tard, on a un control-plane (3 nœuds) et un worker (via le MachineDeployment capi-ovh-md-0) qui tournent sur OVH :
$ kubectl --kubeconfig capi-ovh.kubeconfig get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP EXTERNAL-IP
capi-ovh-cp-jz4h9 Ready control-plane 4m14s v1.36.1 10.6.0.214 <none>
capi-ovh-cp-p8xq2 Ready control-plane 3m52s v1.36.1 10.6.0.61 <none>
capi-ovh-cp-t7v6d Ready control-plane 3m39s v1.36.1 10.6.0.128 <none>
capi-ovh-md-0-7wkzs-lws49 Ready <none> 2m48s v1.36.1 10.6.0.180 <none>
Pour que les nodes passent en Ready, il a fallu installer les addons habituels : un CNI (Cilium, posé via Helm) et surtout l’OpenStack Cloud Controller Manager, qui se charge de réconcilier les providerID des nodes (la même histoire de spec.providerID que dans l’article précédent).
Installer Karpenter (version cluster-api)
Maintenant que nous avons notre bac-à-sable, on va pouvoir greffer Karpenter à CAPI.
Côté cluster de workload, on pose les deux CRDs vues plus haut : le NodePool (les contraintes) et la ClusterAPINodeClass (le “comment”, quasi vide ici puisque la CAPI gère la majorité du travail). On reste volontairement permissif sur les requirements (juste amd64 + on-demand), on plafonne à 20 vCPU avec limits, et on configure une consolidation WhenEmpty (Karpenter ne supprimera que les nodes vides) :
apiVersion: karpenter.cluster.x-k8s.io/v1alpha1
kind: ClusterAPINodeClass
metadata:
name: default
spec: {}
---
apiVersion: karpenter.sh/v1
kind: NodePool
metadata:
name: default
spec:
template:
spec:
requirements:
- key: kubernetes.io/arch
operator: In
values: ["amd64"]
- key: karpenter.sh/capacity-type
operator: In
values: ["on-demand"] # soit spot, soit on-demand. OVH ne propose pas de spot, donc on-demand dans notre cas.
nodeClassRef:
group: karpenter.cluster.x-k8s.io
kind: ClusterAPINodeClass
name: default
expireAfter: Never
limits:
cpu: "20"
disruption:
consolidationPolicy: WhenEmpty
consolidateAfter: 30s
Côté management, Karpenter a besoin d’un MachineDeployment qu’il pourra piloter, avec une petite subtilité. Le label node.cluster.x-k8s.io/karpenter-member: "" désigne ce MachineDeployment comme étant “pilotable” par Karpenter : c’est lui que le provider a le droit de scaler (et pas le capi-ovh-md-0 qu’on a utilisé pour déployer notre worker actuel).
apiVersion: cluster.x-k8s.io/v1beta2
kind: MachineDeployment
metadata:
name: capi-ovh-karpenter
labels:
node.cluster.x-k8s.io/karpenter-member: ""
annotations:
capacity.cluster-autoscaler.kubernetes.io/cpu: "2"
capacity.cluster-autoscaler.kubernetes.io/memory: "4G"
capacity.cluster-autoscaler.kubernetes.io/maxPods: "110"
capacity.cluster-autoscaler.kubernetes.io/labels: "kubernetes.io/arch=amd64,topology.kubernetes.io/zone=nova,node.kubernetes.io/instance-type=c3-4,karpenter.sh/capacity-type=on-demand"
spec:
clusterName: capi-ovh
replicas: 0
# ...
Avertissement
L’annotation capacity.cluster-autoscaler.kubernetes.io/labels de la MachineDeployment éligible Karpenter doit posséder les annotations karpenter.sh/capacity-type=on-demand en plus des classiques kubernetes.io/arch, topology.kubernetes.io/zone et node.kubernetes.io/instance-type. Sans ça, le NodeClaim créé a capacity-type="", ne satisfait pas le requirement In [on-demand] du NodePool, et Karpenter le considère immédiatement comme “drifté” → suppression → recréation → boucle. J’ai eu ce cas durant mes expérimentations.
Reste à déployer le contrôleur. C’est un seul binaire qui parle à deux clusters : le cœur de Karpenter surveille les NodePool/NodeClaim/pods (côté workload), pendant que le provider scale les MachineDeployment (côté management, MKS). Il faut donc un kubeconfig qui relie les deux.
La doc officielle du provider recommande de faire tourner Karpenter dans le cluster workload : il utilise alors l’in-cluster config (comme les opérateurs standard) pour voir les ressources de son cluster, et peut également joindre le cluster de management via le flag --cluster-api-kubeconfig (géré par les values de la chart).
Mais, mauvaise surprise : le provider n’a pas d’image conteneur publiée de façon pullable (le chart pointe sur gcr.io/k8s-staging-... mais le registre renvoie un 401). On build donc la nôtre. Le repo fournit un Dockerfile multi-arch, un docker buildx suffit :
git clone https://github.com/kubernetes-sigs/karpenter-provider-cluster-api
cd karpenter-provider-cluster-api
docker buildx build --platform linux/amd64 \
-t ghcr.io/qjoly/karpenter-clusterapi-controller:poc --push .
inutile de rappeler que vous ne devez pas utiliser cette image, elle n’est pas maintenue et vous n’avez aucune garantie de sécurité.
Une fois qu’on a une image valide. On installe ensuite le chart charts/karpenter. Dans mon cas, j’ai monté le secret mgmt-kubeconfig contenant le kubeconfig du cluster de management.
# kubeconfig du MANAGEMENT (MKS), monté dans le workload
kubectl -n karpenter create secret generic mgmt-kubeconfig --from-file=mgmt.kubeconfig=mks.kubeconfig
helm upgrade --install karpenter ./charts/karpenter -n karpenter --skip-crds \
--set image.repo=ghcr.io/qjoly/karpenter-clusterapi-controller --set image.tag=poc \
--set env.clusterAPIKubeconfig=/etc/mgmt/mgmt.kubeconfig \
--set 'volumes[0].name=mgmt-kubeconfig,volumes[0].secret.secretName=mgmt-kubeconfig' \
--set 'volumeMounts[0].name=mgmt-kubeconfig,volumeMounts[0].mountPath=/etc/mgmt'
Et en prod, ce kubeconfig du management ne doit surtout pas être admin : c’est un accès du workload vers le hub (on y revient dans la partie sécu), à réduire au strict nécessaire par RBAC (
Machine/MachineDeploymentdu namespace concerné).
Information
Durant mes tests, j’étais d’abord parti sur un setup à la ClusterAPI : le contrôleur qui gère l’autoscaling tourne côté management, pas côté workload.
C’est jouable avec Karpenter, en désactivant les webhooks et en posant les bonnes variables d’environnement (SYSTEM_NAMESPACE, DISABLE_WEBHOOK, CLUSTER_API_KUBECONFIG). Mais ça n’est pas la reco : le provider Karpenter est fait pour tourner dans le cluster qu’il scale.
kubectl -n karpenter set env deployment/karpenter \
SYSTEM_NAMESPACE=karpenter DISABLE_WEBHOOK=true
Honnêtement, je suis un peu déçu par cette limitation qui rajoute des contraintes de sécurité : pouvoir joindre le cluster “parent” ne devrait pas être normal, et j’espère que ça sera corrigé dans une prochaine version du provider.
Histoire que ça soit clair, voici ce qui tourne et où :
| Ressource | Cluster | Rôle |
|---|---|---|
NodePool, ClusterAPINodeClass | Workload | les règles (ce que Karpenter a le droit de créer) |
NodeClaim | Workload | la demande concrète d’une machine |
Pods Pending, Node | Workload | ce que Karpenter observe et ce qu’il obtient |
MachineDeployment, Machine | Management (MKS) | ce que le provider scale réellement |
| Contrôleur Karpenter (le pod) | Workload | tourne in-cluster ; lit/écrit les Machine du MKS via --cluster-api-kubeconfig |
Comment on teste le scale-up
Pour prouver que le scale-up, lui, fonctionne, il suffit de mettre un déploiement qui restera en pending parce que les nodes actuels ne sont pas disponibles, comme par exemple avec une AntiAffinity (en temps normal, on scale par manque de ressources, mais rien ne nous empêche d’exclure les nodes actuels pour simuler le manque de ressource).
apiVersion: apps/v1
kind: Deployment
metadata:
name: inflate
spec:
replicas: 2
selector:
matchLabels:
app: inflate
template:
metadata:
labels:
app: inflate
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: inflate
topologyKey: kubernetes.io/hostname
containers:
- name: pause
image: registry.k8s.io/pause:3.10
resources:
requests:
cpu: "500m"
memory: "256Mi"
Premier essai : ça démarre, puis ça disparaît
Je lâche ce déploiement, et au début tout a l’air de fonctionner : Karpenter détecte le pod Pending, passe le MachineDeployment capi-ovh-karpenter de 0 à 1, CAPO provisionne une instance OVH… qui démarre bien et rejoint le cluster. Sauf que le pod reste désespérément en Pending, et au bout d’un moment le node fraîchement créé disparaît.
En regardant de plus près, le NodeClaim correspondant restait bloqué en Registered=False. Mon node démarrait bien, rejoignait bien le cluster… mais Karpenter refusait de le considérer comme “le sien”. Au bout d’un moment, il finit par voir ça comme un échec de déploiement et supprime la machine.
On retourne au débug, surtout que les logs ne sont pas très bavards par défaut.
La taint attendue
En lisant le code du reconciler d’enregistrement de Karpenter (le contrôleur nodeclaim/lifecycle dans kubernetes-sigs/karpenter), ça devient plus clair : Karpenter exige que tout nouveau node arrive avec un taint karpenter.sh/unregistered. Pour distinguer un node qu’il a lui-même provisionné d’un scaling manuel. Une fois qu’il reconnait la machine qu’il a commandée, il lui retire le taint (et sinon, il attend jusqu’à timeout).
Pour fixer ça, on met directement cette taint dans le KubeadmConfigTemplate qui sera utilisé par Karpenter :
apiVersion: bootstrap.cluster.x-k8s.io/v1beta2
kind: KubeadmConfigTemplate
metadata:
name: capi-ovh-karpenter
spec:
template:
spec:
joinConfiguration:
nodeRegistration:
# ...
taints:
- key: karpenter.sh/unregistered
effect: NoExecute
On relance le déploiement inflate et on croise les doigts. 🤞
Deuxième essai : le scale-up complet
Avec le taint en place, on rejoue le déploiement inflate et là, ça marche : Karpenter détecte le pod Pending, passe le MachineDeployment capi-ovh-karpenter de 0 à 1, CAPO provisionne une instance OVH, le node s’enregistre, Karpenter retire le taint, et le pod se schedule dessus. Sur ce run (image Ubuntu bare, c3-4), on peut apercevoir les étapes via le status du NodeClaim :
Launched: le provider a déclenché la création de la machine ;Registered: leNodeKubernetes est apparu et a été “adopté” par Karpenter. C’est ici qu’intervient le fameux taintkarpenter.sh/unregistered: Karpenter le valide, puis le retire lui-même. C’est précisément l’étape qui échouait au premier essai ;Initialized: le node est totalement prêt (Ready, startup taints retirés). Les pods peuvent enfin s’y poser.
Coté vitesse d’adoption, voici ce que ça donne :
T+0s kubectl apply -f inflate.yaml
T+2s Karpenter crée le NodeClaim default-mrnnj (instance-type c3-4)
T+66s CAPO a monté le MD à 1, OpenStack boote la VM
T+74s Node Registered, taint karpenter.sh/unregistered retiré
T+141s Node Initialized (Ready=True), enfin schedulable
T+153s Les deux pods inflate sont Running
Il faut aussi rappeler que le node passe Registered à T+74s mais n’est réellement Initialized (donc schedulable) qu’à T+141s. Ces ~67 secondes, c’est l’image Ubuntu bare qui installe containerd, kubelet et les paquets Kubernetes après l’enregistrement. On n’aurait pas eu ce délai avec une image pré-packagée en production, mais c’est un PoC, donc on reste sur du bare.
Et le scale-to-zero ?
Un autoscaler qui ne sait que grossir ne sert pas à grand-chose à part vider le portefeuille. Je supprime le déploiement inflate et je regarde Karpenter faire le ménage :
$ kubectl delete deploy inflate
# dans les logs du contrôleur :
"disrupting node(s)" reason="empty" decision="delete" \
disrupted-node-count=1 replacement-node-count=0 pod-count=0 \
disrupted-nodes=[{"Node":"capi-ovh-karpenter-w6gt5-286tq", ...}]
"tainted node" taint.Key="karpenter.sh/disrupted"
La consolidation WhenEmpty fait bien son travail : reason=empty, decision=delete, zéro remplacement. Environ 3 minutes après le retrait de la charge, tous les NodeClaim ont disparu et le MachineDeployment est revenu à replicas: 0. Le scale-to-zero est donc démontrable.
Une grosse différence entre Karpenter et CAPI : Karpenter raisonne “supprime ce node précis”, alors que le provider cluster-api matérialise ça en décrémentant le nombre de replicas de la MachineDeployment, et c’est ensuite CAPI qui choisit quelle Machine retirer (et parfois, c’est la machine qui porte un pod essentiel, ce qui implique des coûts pour déplacer des pods durant l’étape “Drain”).
Bref, Karpenter est un bon produit qui gère entièrement le scale-up et scale-down.. mais on ne va pas s’arrêter là !
Karpenter vs cluster-autoscaler : le benchmark
Tant qu’à avoir l’infra sous la main, j’ai voulu confronter Karpenter à son grand rival : le cluster-autoscaler. Lui aussi sait piloter des MachineDeployments CAPI, il se contente de bouger le nombre de replicas, là où Karpenter raisonne en NodeClaims.
Le protocole qui va les tester, identique pour les deux :
- trois flavors offerts à l’autoscaler :
c3-4(2 vCPU),c3-8(4 vCPU) etc3-16(8 vCPU) ; - une charge
inflatede 8 pods × 1 vCPU, lâchée d’un coup sur un pool froid (le worker de base estcordonné, le control-plane esttainté) : les 8 pods sontPending, l’autoscaler doit tout provisionner depuis zéro ; - on mesure le temps jusqu’à les 8 pods
Running, le nombre de nœuds créés, et les flavors choisis.
Le cluster-autoscaler, je l’ai déployé directement dans le cluster de management (sachant que les deux modes sont possibles).
Information
Faisons déjà un petit calcul de packing avant de lancer le bench.
Une fois retiré l’overhead kubelet/système + le DaemonSet cilium : il peut y avoir ~7 pods sur un c3-16 (8 vCPU), 3 pods sur un c3-8 (4 vCPU), ~1 pod sur un c3-4 (2 vCPU). Pour caser 8 pods, il y a donc deux manières “raisonnables” de faire :
- 3 × c3-8 → 12 vCPU, packing 3+3+2 ;
- 1 × c3-16 + 1 × c3-4 → 10 vCPU, packing 7+1.
Gardez ces deux options en tête.
Les résultats (un run par autoscaler, image bare) :
| Métrique | Karpenter | cluster-autoscaler |
|---|---|---|
Temps jusqu’aux 8 pods Running | ~136 s | ~135 s |
| Premier nœud utilisable | ~105 s | ~135 s |
| Flavors choisis | 1× c3-16 + 1× c3-4 | 3× c3-8 |
| Packing des pods | 7 + 1 | 3 + 3 + 2 |
| Machines créées | 2 | 3 |
| vCPU provisionnés / demandés | 10 / 8 | 12 / 8 |
Surprise par rapport à ce que j’attendais : pas de gagnant net sur le temps. Les deux atteignent 8 pods Running en ~135 s, parce que le facteur limitant n’est ni Karpenter ni le CA, c’est le boot de l’image Ubuntu bare (~2 min pour installer containerd + kubelet). Le handshake NodeClaim → Machine de Karpenter n’est pas si pénalisant que ça.
Plus intéressant : Karpenter a provisionné moins que le cluster-autoscaler (2 nodes / 10 vCPU contre 3 / 12). Son bin-packing a tassé 7 pods sur un c3-16 + 1 sur un c3-4, là où le CA en least-waste a préféré 3× c3-8. Mais attention au piège : ce n’est pas que Karpenter “optimise mieux”. Il a pris c3-16 un peu par accident 😅, on va creuser la façon dont il choisit sa flavor.
Le provider cluster-api pour Karpenter dans ce bout de code code en dur Price: 0.0 pour tous les types d’instances :
offerings := cloudprovider.Offerings{
cloudprovider.Offering{ Requirements: ..., Price: 0.0, Available: true },
}
Résultat : il n’a aucune idée du prix des flavors, donc aucun moyen de les départager par le coût. Alors comment tranche-t-il entre deux flavors qui conviennent toutes les deux ? Réalisons un nouveau test pour mieux comprendre !
La démonstration : trois tailles, le mauvais choix
Deuxième test, plus ciblé cette fois. Pour rendre ça concret, j’ai toujours les trois MachineDeployments éligibles (c3-4 (2 vCPU), c3-8 (4 vCPU) et c3-16 (8 vCPU)), puis je lâche un seul pod demandant 3 vCPU : il ne rentre pas sur c3-4, le choix optimal est c3-8, et c3-16 est overkill.
Côté Karpenter, le NodeClaim calcule bien les types compatibles.. puis le provider en choisit un :
created nodeclaim NodePool="default" instance-types="c3-16, c3-8" requests cpu=3100m
launched nodeclaim instance-type="c3-16" allocatable cpu=8
Il a pris c3-16 (8 vCPU pour un besoin de 3). Pourquoi le plus gros ? Parce qu’en l’absence de prix pour départager, le provider trie les types compatibles par nom et prend le premier ("c3-16" < "c3-8" en ordre lexicographique). Le commentaire du code est assez clair : “if multiple instance types are found to be compatible we need to select one. for now, we sort by resource name and take the first”.
Côté cluster-autoscaler (--expander=least-waste), le même pod, le même choix de tailles :
Expanding Node Group .../capi-ovh-karpenter-c3-16 would waste 62.50% CPU ...
Expanding Node Group .../capi-ovh-karpenter-c3-8 would waste 25.00% CPU ...
Best option to resize: .../capi-ovh-karpenter-c3-8
Final scale-up plan: [capi-ovh-karpenter-c3-8 0->1]
Il calcule explicitement la taille parfaite et choisit c3-8. Même charge, même catalogue de flavors : cluster-autoscaler provisionne 4 vCPU, Karpenter en provisionne 8. Je crois que nous avons trouvé un gagnant.
Conclusion
Pouvoir autoscale des nodes sur Kubernetes reste un besoin assez complexe, le pilotage entre les ressources Kubernetes et les ressources cloud apporte plein de subtilités et contraintes, sans même parler du scale-down qui rajoute les siennes.
J’ai eu l’occasion de voir à l’oeuvre un Karpenter et son provider officiel durant le live avec Rémi Verchère sur les GPUs dans Kubernetes et c’était très impressionnant. Dans un contexte CAPI, le constat est plus nuancé que je ne l’imaginais : à l’usage, les deux autoscalers pour CAPI se tiennent (temps de scale-up équivalents).
Pour l’instant, mon opinion est que Karpenter est un projet très prometteur, mais dès qu’il s’agit de passer par le provider CAPI, il est moins efficace que Cluster-Autoscaler. Il manque la notion de pricing et le sens workload qui communique avec management ne me plait pas trop, même si effectivement Karpenter semble plus rapide dans la création des nodes. Le jour où ces points seront réglés, Karpenter pourra enfin jouer son vrai rôle sur CAPI. On en reparlera :D.
Un énorme merci à OVH pour m’avoir permis de tester tout ça sur leur plateforme 🫶 C’est grâce à leur aide que j’ai pu écrire cet article dans de bonnes conditions (de plus, il y a un bon de 200€ sur la partie “Cloud Public”, suffisant pour tester le provider CAPO en profondeur).
Bon kawa ☕ !
