Aller au contenu
Conteneurs & Orchestration medium

Développer un provider Karpenter : guide pas à pas

38 min de lecture

Karpenter fonctionne sur AWS, mais rien ne vous empêche de l'adapter à votre cloud. Ce guide documente le développement complet d'un provider Karpenter pour Outscale avec Talos Linux, de l'interface CloudProvider aux bugs qui suppriment tous vos nœuds en 30 secondes.

  • Implémenter les 9 méthodes de l'interface CloudProvider (Create, Delete, List, Get...)
  • Éviter les bugs critiques : GC destructeur, tags manquants, signaux D-Bus
  • Debugger efficacement avec osc-cli quand les nodes disparaissent
  • Tester votre provider avec un cycle build/deploy rapide

Développer un provider Karpenter, c'est faire fonctionner 6 composants ensemble. Chacun a ses propres attentes, et si l'un d'eux reçoit des données incorrectes, le système entier peut s'effondrer.

Voici les acteurs en jeu :

Architecture d'un provider Karpenter : votre provider s'intègre avec Karpenter Core, CCM, Kubelet, l'API Cloud et l'OS du nœud

Avant d'écrire du code, comprenez ce qui se passe quand un pod est en Pending :

Flux de provisionnement Karpenter en 5 étapes : Détection, Création, Boot, Initialisation CCM, Ready

Chaque flèche est un point de défaillance. C'est pourquoi il faut comprendre ce flux avant de coder.

Un provider Karpenter est un contrôleur Kubernetes écrit en Go, livré sous forme d'image de conteneur. L'outillage se résume donc à une chaîne Go, un moteur de build d'images, un cluster de test et, surtout, la CLI de votre cloud. Cette dernière n'est pas un confort : c'est le seul moyen de voir ce que votre code a réellement créé du côté du fournisseur.

La version de Go n'est pas négociable, elle est imposée par le module sigs.k8s.io/karpenter que vous importez : une version antérieure fait échouer la compilation avant même votre premier commit.

Fenêtre de terminal
# Go 1.25+ (Karpenter v1.9 l'exige)
go version
# go1.25.7 linux/amd64
# Docker pour builder les images
docker --version
# kubectl configuré sur un cluster de test
kubectl cluster-info
# CLI de votre cloud pour debugger les VMs
# AWS : aws-cli, GCP : gcloud, Azure : az, Outscale : osc-cli

L'outil de debug indispensable : la CLI de votre cloud

Section intitulée « L'outil de debug indispensable : la CLI de votre cloud »

Quand quelque chose ne fonctionne pas, vous devez inspecter vos VMs directement dans le cloud. Voici les opérations essentielles (adaptez à votre CLI) :

Fenêtre de terminal
# Lister toutes les VMs gérées par Karpenter
# Filtrer par le tag "karpenter.sh/managed-by"
<cloud-cli> describe-instances --filters "tag:karpenter.sh/managed-by=<cluster>"
# Voir les tags d'une VM spécifique
<cloud-cli> describe-instances --instance-id <vm-id> | jq '.Tags'
# Vérifier l'état d'une VM
<cloud-cli> describe-instances --instance-id <vm-id> | jq '{Id, State, PrivateIp}'
# Lire les logs de console (boot de l'OS)
<cloud-cli> get-console-output --instance-id <vm-id> | base64 -d | tail -50

L'arborescence ci-dessous reprend celle du provider AWS, ce qui n'est pas cosmétique : quand vous chercherez comment une méthode est implémentée en amont, vous retrouverez le fichier au même endroit. Les deux répertoires qui portent l'essentiel du travail sont pkg/cloudprovider/, où vit l'interface à implémenter, et pkg/vm/, qui isole tous les appels à l'API de votre cloud derrière un service unique.

Voici l'organisation recommandée :

karpenter-provider-<cloud>/
├── cmd/controller/main.go ← Point d'entrée
├── pkg/
│ ├── apis/v1alpha1/ ← Votre CRD NodeClass
│ │ ├── <cloud>nodeclass_types.go
│ │ ├── register.go
│ │ └── hash.go ← Pour drift detection
│ │
│ ├── cloudprovider/ ← L'interface CloudProvider
│ │ └── cloudprovider.go ← Create, Delete, List, Get...
│ │
│ ├── vm/ ← Wrapper API cloud
│ │ └── vm.go ← Appels API avec retry
│ │
│ ├── instancetype/ ← Catalogue des types d'instances
│ │ └── instancetypes.go
│ │
│ └── utils/ ← Helpers (providerID, hostname)
│ └── utils.go
├── charts/ ← Helm chart pour déploiement
│ └── karpenter-provider-<cloud>/
├── Dockerfile
└── go.mod

Les versions de sigs.k8s.io/karpenter, de controller-runtime et des paquets k8s.io doivent être cohérentes entre elles : Karpenter Core fixe la version de controller-runtime qu'il attend, et un décalage se traduit par des erreurs de compilation difficiles à interpréter, sur des types apparemment identiques.

go.mod
module github.com/<org>/karpenter-provider-<cloud>
go 1.25.7
require (
// Karpenter Core - L'INTERFACE que vous implémentez
sigs.k8s.io/karpenter v1.9.1
// Controller-runtime - Pour créer des controllers K8s
sigs.k8s.io/controller-runtime v0.22.4
// SDK de votre cloud (remplacer par le SDK approprié)
// github.com/<cloud-provider>/<sdk> vX.Y.Z
// Kubernetes API
k8s.io/api v0.35.0
k8s.io/apimachinery v0.35.0
k8s.io/client-go v0.35.0
)

La NodeClass contient la configuration spécifique à votre cloud. Les utilisateurs la créent pour dire "voici comment créer mes nœuds".

Karpenter Core ne connaît que des notions portables : des ressources demandées, des contraintes de placement, des types d'instances. Tout ce qui est propre à un fournisseur est délibérément renvoyé à votre CRD (Custom Resource Definition), la NodeClass. C'est ce découpage qui permet au même NodePool de fonctionner sur n'importe quel cloud.

Sans NodeClass, Karpenter ne sait pas :

  • Quelle image utiliser (AMI/OMI)
  • Dans quel subnet créer la VM
  • Quels security groups attacher
  • Comment initialiser l'OS (user-data)

Les balises json de chaque champ deviennent les clés du manifeste YAML que vos utilisateurs écriront, et un champ sans omitempty est obligatoire pour eux. Le fichier hash.go évoqué dans l'arborescence calcule une empreinte de cette structure : c'est elle qui permet à IsDrifted() de repérer qu'une NodeClass a changé depuis la création d'un nœud.

// pkg/apis/v1alpha1/<cloud>nodeclass_types.go
type <Cloud>NodeClassSpec struct {
// Où créer les VMs
SubnetIDs []string `json:"subnetIds"`
SecurityGroupIDs []string `json:"securityGroupIds"`
// Quelle image utiliser
ImageID string `json:"imageId"` // OMI Talos
// Pour SSH debug (optionnel)
KeypairName string `json:"keypairName,omitempty"`
// Tags additionnels
Tags map[string]string `json:"tags,omitempty"`
// Config disques
BlockDeviceMappings []BlockDeviceMapping `json:"blockDeviceMappings,omitempty"`
// Config spécifique Talos
Talos TalosConfig `json:"talos"`
}

Déclarer plusieurs sous-réseaux dans des zones différentes est ce qui donne à Karpenter la liberté de placer un nœud là où la capacité est disponible. C'est votre méthode Create() qui tranchera, à partir des contraintes portées par le NodeClaim.

apiVersion: karpenter.<cloud>.com/v1alpha1
kind: <Cloud>NodeClass
metadata:
name: default
spec:
imageId: "ami-xxxx" # Image de votre OS (Talos, Bottlerocket...)
subnetIds:
- "subnet-aaa" # Zone A
- "subnet-bbb" # Zone B
securityGroupIds:
- "sg-xxxx"
keypairName: "my-keypair" # Optionnel, pour debug SSH
tags:
Cluster: "prod"
Environment: "production"

C'est le cœur de votre provider. Voici les 9 méthodes à implémenter :

Les neuf méthodes ne se valent pas. Trois d'entre elles suffisent à provisionner un nœud, mais c'est List() qui décide de la survie de votre parc : elle est interrogée en boucle par le GC Controller, le composant chargé de supprimer les NodeClaims dont la machine n'existe plus. La colonne « Criticité » indique où concentrer vos tests.

MéthodeAppelée parCe qu'elle faitCriticité
Create()ProvisionerCrée une VMHaute
Delete()TerminationSupprime une VMHaute
Get()PlusieursRécupère l'état d'une VMMoyenne
List()GC ControllerListe TOUTES les VMs géréesCRITIQUE
GetInstanceTypes()ProvisionerCatalogue des typesMoyenne
IsDrifted()DisruptionDétecte les changements de configMoyenne
Name()CoreRetourne "<cloud>"Faible
GetSupportedNodeClasses()CoreRetourne vos CRDsFaible
RepairPolicies()CoreRègles de réparationFaible

Les tags sont critiques. Ils permettent à différents composants de retrouver vos VMs.

TagValeurUtilisé parSi absent...
karpenter.sh/managed-by<cluster-name>GC (List())Tous les nodes sont supprimés en 30s
<cloud>/node-name<hostname>CCM de votre cloudNode reste Initialized=Unknown
karpenter.sh/nodeclaim<nodeclaim-name>TraçabilitéDebug difficile
karpenter.sh/nodepool<nodepool-name>TraçabilitéDebug difficile

La méthode enchaîne sept opérations dans un ordre imposé, et deux d'entre elles n'ont rien d'évident. L'étape 4 pose les tags sans lesquels la machine sera considérée comme orpheline dès le passage suivant du ramasse-miettes. L'étape 7 remplit le NodeClaim : le ProviderID, les labels de type d'instance et de zone, et la capacité annoncée. Karpenter s'appuie sur ces valeurs pour planifier les pods avant même que le nœud ait rejoint le cluster, un écart avec la réalité produit donc des pods qui ne tiennent pas sur la machine.

func (c *CloudProvider) Create(ctx context.Context, nodeClaim *karpv1.NodeClaim) (*karpv1.NodeClaim, error) {
logger := log.FromContext(ctx)
// ═══════════════════════════════════════════════════════════════
// ÉTAPE 1 : Récupérer la NodeClass
// ═══════════════════════════════════════════════════════════════
// La NodeClass contient la config cloud (image, subnet, etc.)
nodeClass, err := c.getNodeClass(ctx, nodeClaim)
if err != nil {
return nil, fmt.Errorf("failed to get node class: %w", err)
}
logger.Info("NodeClass loaded",
"imageId", nodeClass.Spec.ImageID,
"subnets", nodeClass.Spec.SubnetIDs,
)
// ═══════════════════════════════════════════════════════════════
// ÉTAPE 2 : Choisir le type d'instance
// ═══════════════════════════════════════════════════════════════
// Basé sur les requirements du NodeClaim (CPU, mémoire, GPU...)
instanceType := c.selectInstanceType(nodeClaim)
logger.Info("Instance type selected", "type", instanceType)
// ═══════════════════════════════════════════════════════════════
// ÉTAPE 3 : Générer le user-data
// ═══════════════════════════════════════════════════════════════
// C'est la config d'initialisation du nœud (Talos machine config)
userData, err := c.generateUserData(ctx, nodeClass, nodeClaim)
if err != nil {
return nil, fmt.Errorf("failed to generate user-data: %w", err)
}
// ═══════════════════════════════════════════════════════════════
// ÉTAPE 4 : Construire les TAGS (CRITIQUE !)
// ═══════════════════════════════════════════════════════════════
tags := map[string]string{
// ⚠️ OBLIGATOIRE : Le GC utilise ce tag pour List()
"karpenter.sh/managed-by": c.clusterName,
// Traçabilité
"karpenter.sh/nodeclaim": nodeClaim.Name,
"karpenter.sh/nodepool": nodeClaim.Labels["karpenter.sh/nodepool"],
// Standard K8s
"kubernetes.io/cluster/" + c.clusterName: "owned",
}
// Copier les tags de la NodeClass
for k, v := range nodeClass.Spec.Tags {
tags[k] = v
}
// ═══════════════════════════════════════════════════════════════
// ÉTAPE 5 : Créer la VM via l'API cloud
// ═══════════════════════════════════════════════════════════════
vmOutput, err := c.vmService.Create(ctx, &vm.CreateInput{
InstanceType: instanceType,
ImageID: nodeClass.Spec.ImageID,
SubnetID: c.selectSubnet(nodeClass, nodeClaim),
SecurityGroupIDs: nodeClass.Spec.SecurityGroupIDs,
KeypairName: nodeClass.Spec.KeypairName,
UserData: userData,
Tags: tags,
})
if err != nil {
logger.Error(err, "VM creation failed")
return nil, fmt.Errorf("failed to create VM: %w", err)
}
logger.Info("VM created",
"vmID", vmOutput.VMID,
"privateIP", vmOutput.PrivateIP,
"zone", vmOutput.Zone,
)
// ═══════════════════════════════════════════════════════════════
// ÉTAPE 6 : Ajouter le tag pour le CCM
// ═══════════════════════════════════════════════════════════════
// Le CCM de votre cloud a besoin d'un tag pour retrouver la VM
// Consultez la doc de votre CCM pour savoir quel tag utiliser
hostname := utils.IPToHostname(vmOutput.PrivateIP) // "ip-10-0-1-88"
err = c.vmService.UpdateTags(ctx, vmOutput.VMID, map[string]string{
"Name": fmt.Sprintf("%s-%s", c.clusterName, hostname),
"<cloud>/node-name": hostname, // ⚠️ Tag attendu par votre CCM
})
if err != nil {
// Log mais continue - le node sera quand même créé
logger.Error(err, "Failed to update tags (non-fatal)")
}
// ═══════════════════════════════════════════════════════════════
// ÉTAPE 7 : Hydrater le NodeClaim avec les infos
// ═══════════════════════════════════════════════════════════════
// Provider ID (format attendu par le CCM)
nodeClaim.Status.ProviderID = fmt.Sprintf("aws:///%s/%s", vmOutput.Zone, vmOutput.VMID)
nodeClaim.Status.NodeName = hostname
// Labels obligatoires pour Karpenter
nodeClaim.Labels[v1.LabelInstanceTypeStable] = instanceType
nodeClaim.Labels[v1.LabelTopologyZone] = vmOutput.Zone
nodeClaim.Labels[karpv1.CapacityTypeLabelKey] = karpv1.CapacityTypeOnDemand
// Capacity
nodeClaim.Status.Capacity = v1.ResourceList{
v1.ResourceCPU: resource.MustParse("4"),
v1.ResourceMemory: resource.MustParse("16Gi"),
v1.ResourcePods: resource.MustParse("110"),
}
logger.Info("NodeClaim hydrated successfully",
"nodeClaim", nodeClaim.Name,
"providerID", nodeClaim.Status.ProviderID,
"nodeName", nodeClaim.Status.NodeName,
)
return nodeClaim, nil
}

List() est appelé par le GC Controller toutes les 30 secondes. Il doit retourner toutes les VMs gérées par Karpenter.

func (c *CloudProvider) List(ctx context.Context) ([]*karpv1.NodeClaim, error) {
logger := log.FromContext(ctx)
// ═══════════════════════════════════════════════════════════════
// FILTRER PAR LE TAG karpenter.sh/managed-by
// ═══════════════════════════════════════════════════════════════
// C'est le SEUL moyen pour le GC de savoir quelles VMs sont gérées
vms, err := c.vmService.List(ctx, map[string]string{
"karpenter.sh/managed-by": c.clusterName, // ⚠️ DOIT correspondre au tag dans Create()
})
// ═══════════════════════════════════════════════════════════════
// ⚠️ ATTENTION : NE JAMAIS RETOURNER UNE LISTE VIDE EN CAS D'ERREUR
// ═══════════════════════════════════════════════════════════════
// Si vous retournez (nil, nil) ou ([]NodeClaim{}, nil), le GC
// pensera qu'il n'y a aucune VM → il supprimera TOUS les NodeClaims
if err != nil {
logger.Error(err, "Failed to list VMs - returning error to prevent GC disaster")
return nil, fmt.Errorf("failed to list VMs: %w", err) // ← RETOURNER L'ERREUR
}
// Convertir en NodeClaims
var nodeClaims []*karpv1.NodeClaim
for _, vmInfo := range vms {
providerID := fmt.Sprintf("aws:///%s/%s", vmInfo.Zone, vmInfo.VMID)
nodeClaims = append(nodeClaims, &karpv1.NodeClaim{
Status: karpv1.NodeClaimStatus{
ProviderID: providerID,
NodeName: utils.IPToHostname(vmInfo.PrivateIP),
},
})
}
logger.V(1).Info("Listed VMs", "count", len(nodeClaims))
return nodeClaims, nil
}

Delete() est appelée plusieurs fois pour la même machine : Karpenter réessaie tant qu'elle ne signale pas la disparition effective. La convention à respecter tient en deux valeurs de retour. Renvoyer nil signifie « suppression demandée, rappelez-moi », renvoyer une NodeClaimNotFoundError signifie « la machine n'existe plus », ce qui autorise Karpenter à retirer le finalizer et à effacer le NodeClaim. Renvoyer une erreur ordinaire sur une machine déjà supprimée bloque définitivement le NodeClaim en cours de suppression.

func (c *CloudProvider) Delete(ctx context.Context, nodeClaim *karpv1.NodeClaim) error {
logger := log.FromContext(ctx)
if nodeClaim.Status.ProviderID == "" {
// Pas de providerID = la VM n'a jamais été créée
return cloudprovider.NewNodeClaimNotFoundError(
fmt.Errorf("NodeClaim has no provider ID"),
)
}
_, vmID, err := utils.ParseProviderID(nodeClaim.Status.ProviderID)
if err != nil {
return fmt.Errorf("failed to parse provider ID: %w", err)
}
// Vérifier l'état de la VM
vmInfo, err := c.vmService.Get(ctx, vmID)
if err != nil {
if vm.IsVMNotFound(err) {
// VM déjà supprimée → OK, retourner NodeClaimNotFoundError
// Cela permet à Karpenter de retirer les finalizers
return cloudprovider.NewNodeClaimNotFoundError(
fmt.Errorf("VM %s not found", vmID),
)
}
// Erreur transitoire → requeue
return fmt.Errorf("failed to get VM state: %w", err)
}
// VM en cours de suppression ou déjà supprimée
if vmInfo.State == "terminated" || vmInfo.State == "shutting-down" {
return cloudprovider.NewNodeClaimNotFoundError(
fmt.Errorf("VM %s is %s", vmID, vmInfo.State),
)
}
// Demander la suppression
logger.Info("Deleting VM", "vmID", vmID)
if err := c.vmService.Delete(ctx, vmID); err != nil {
return fmt.Errorf("failed to delete VM: %w", err)
}
// Retourner nil = Karpenter va re-appeler Delete() jusqu'à ce que
// la VM soit vraiment terminée (et qu'on retourne NodeClaimNotFoundError)
return nil
}

L'API Outscale (comme toute API cloud) peut échouer transitoirement. Sans retry, une erreur 500 peut déclencher le GC.

Le principe : centraliser tous les appels au cloud dans un service unique, pour que la politique de reprise soit écrite une seule fois. Deux détails de l'exemple méritent l'attention. Le backoff exponentiel espace les tentatives au lieu de matraquer une API déjà en difficulté, et le filtre d'états exclut volontairement terminated, sans quoi des machines détruites depuis longtemps continueraient d'apparaître dans les résultats de List().

pkg/vm/vm.go
func (s *Service) List(ctx context.Context, tags map[string]string) ([]VMInfo, error) {
var lastErr error
// 3 tentatives avec backoff exponentiel
for attempt := 0; attempt < 3; attempt++ {
vms, err := s.listVMsInternal(ctx, tags)
if err == nil {
return vms, nil
}
lastErr = err
backoff := time.Duration(1<<attempt) * time.Second // 1s, 2s, 4s
log.FromContext(ctx).Error(err, "API call failed, retrying",
"attempt", attempt+1,
"backoff", backoff,
)
time.Sleep(backoff)
}
return nil, fmt.Errorf("API failed after 3 attempts: %w", lastErr)
}
func (s *Service) listVMsInternal(ctx context.Context, tags map[string]string) ([]VMInfo, error) {
// Construire le filtre de tags au format Outscale
// Format: "KEY=VALUE"
var tagFilters []string
for k, v := range tags {
tagFilters = append(tagFilters, fmt.Sprintf("%s=%s", k, v))
}
req := osc.ReadVmsRequest{
Filters: &osc.FiltersVm{
Tags: &tagFilters,
VmStateNames: &[]string{"pending", "running", "stopping", "stopped"},
// ⚠️ NE PAS inclure "terminated" sinon les VMs supprimées
// apparaissent dans la liste
},
}
resp, _, err := s.Client.VmApi.ReadVms(s.AuthCtx).ReadVmsRequest(req).Execute()
if err != nil {
return nil, fmt.Errorf("ReadVms failed: %w", err)
}
// Convertir la réponse
var vms []VMInfo
for _, vm := range resp.GetVms() {
vms = append(vms, VMInfo{
VMID: vm.GetVmId(),
State: vm.GetState(),
PrivateIP: vm.GetPrivateIp(),
Zone: vm.GetPlacement().GetSubregionName(),
ImageID: vm.GetImageId(),
})
}
return vms, nil
}

Vous ne pouvez pas exécuter ce contrôleur sur votre poste : il lui faut les identifiants du cloud, l'accès à l'API Kubernetes et des CRDs installées. Chaque modification passe donc par un cycle compiler, publier l'image, redéployer. Automatiser ce cycle dans un script fait gagner un temps considérable sur un projet où l'on itère des dizaines de fois par jour.

Le point clé de ce script est le numéro de version dans le tag de l'image. Réutiliser un tag existant conduit Kubernetes à conserver l'image déjà en cache sur le nœud : vous déboguez alors l'ancien binaire en croyant tester le nouveau.

scripts/build-karpenter.sh
#!/bin/bash
VERSION="${1:-v0.2.63}"
REGISTRY="10.100.4.10"
cd ~/talos-dev/karpenter-provider-outscale
# 1. Compiler
echo "=== Go build ==="
go build ./...
if [ $? -ne 0 ]; then
echo "❌ Build failed"
exit 1
fi
# 2. Docker build
echo "=== Docker build ==="
docker build --no-cache -t karpenter-provider-outscale:${VERSION} .
# 3. Push
echo "=== Push to registry ==="
docker tag karpenter-provider-outscale:${VERSION} ${REGISTRY}/karpenter/karpenter-provider-outscale:${VERSION}
docker push ${REGISTRY}/karpenter/karpenter-provider-outscale:${VERSION}
# 4. Déployer
echo "=== Deploy ==="
kubectl set image deployment/karpenter-provider-outscale \
controller=${REGISTRY}/karpenter/karpenter-provider-outscale:${VERSION} \
-n karpenter-system
# 5. Attendre
echo "=== Wait for rollout ==="
kubectl rollout status deployment/karpenter-provider-outscale -n karpenter-system --timeout=120s
echo ""
echo "✅ Done! Version ${VERSION} deployed."
echo "Logs: kubectl logs -n karpenter-system deployment/karpenter-provider-outscale -f"

Le déploiement inflate est le test de référence de la communauté Karpenter : il ne fait rien d'utile, il réserve simplement du CPU et de la mémoire pour forcer le passage de pods en Pending. Créé avec replicas: 0, il n'a aucun effet tant que vous ne déclenchez pas la montée en charge, ce qui vous laisse le temps d'ouvrir les deux terminaux d'observation.

Fenêtre de terminal
# 1. Créer un deployment de test
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: inflate
spec:
replicas: 0
selector:
matchLabels:
app: inflate
template:
metadata:
labels:
app: inflate
spec:
containers:
- name: inflate
image: nginx:1.31.3-alpine@sha256:4a73073bd557c65b759505da037898b61f1be6cbcc3c2c3aeac22d2a470c1752
resources:
requests:
cpu: 1
memory: 1Gi
EOF
# 2. Déclencher le scale-up
kubectl scale deployment/inflate --replicas=3
# 3. Observer dans 2 terminaux
# Terminal 1 : NodeClaims
watch -n2 'kubectl get nodeclaims -o wide'
# Terminal 2 : Logs
kubectl logs -n karpenter-system deployment/karpenter-provider-<cloud> -f | grep -E "Create|List|Delete"

Ces durées servent de référence pour situer une panne : c'est l'étape où la progression s'arrête qui désigne le composant fautif. Un blocage avant T+60 s met en cause votre méthode Create() ou l'image de l'OS ; un nœud qui reste NotReady au-delà de deux minutes pointe vers le CCM ou le plugin réseau, pas vers votre provider.

TempsCe qui se passe
T+0skubectl scale → pods Pending
T+2sKarpenter détecte → appelle Create()
T+5sVM en création dans le cloud
T+30-60sVM running, kubelet démarre
T+60-90skubelet s'enregistre → Node apparaît (NotReady)
T+90-120sCNI ready → Node Ready
T+120s+Taint retiré → pods schedulés

Voici les problèmes les plus fréquents et comment les diagnostiquer :

"Le NodeClaim reste Launched mais jamais Registered"

Section intitulée « "Le NodeClaim reste Launched mais jamais Registered" »

Launched signifie que votre Create() a réussi, Registered que le kubelet de la machine a rejoint le cluster. Rester coincé entre les deux innocente donc votre code de création : le problème se situe dans le démarrage de l'OS ou dans sa configuration réseau, et les logs de console sont la seule fenêtre sur cette phase.

Fenêtre de terminal
# 1. Trouver le VM ID dans le NodeClaim
kubectl get nodeclaim <name> -o jsonpath='{.status.providerID}'
# <scheme>:///<zone>/<vm-id>
# 2. Vérifier que la VM existe dans votre cloud
<cloud-cli> describe-instances --instance-id <vm-id> | jq '.State'
# "running" → OK, la VM existe
# 3. Vérifier les tags
<cloud-cli> describe-instances --instance-id <vm-id> | jq '.Tags'
# Chercher karpenter.sh/managed-by et le tag attendu par votre CCM
# 4. Lire les logs de boot
<cloud-cli> get-console-output --instance-id <vm-id> | base64 -d | grep -E "kubelet|error|fail" | tail -30

"Les nodes sont créés puis supprimés après 30s"

Section intitulée « "Les nodes sont créés puis supprimés après 30s" »

C'est le bug GC. La régularité du délai est le signe qui ne trompe pas : trente secondes correspond à la période de réconciliation du GC Controller. La cause est presque toujours un écart entre la valeur du tag posée par Create() et celle recherchée par List(), une différence de casse suffit.

Fenêtre de terminal
# 1. Vérifier que List() retourne les VMs
kubectl logs -n karpenter-system deployment/karpenter-provider-<cloud> \
| grep "Listed VMs" | tail -5
# Chercher "count": X - si X=0, le tag est manquant
# 2. Vérifier les tags dans le cloud
<cloud-cli> describe-instances --filters "tag-key=karpenter.sh/managed-by"
# Si vide → les tags ne sont pas ajoutés dans Create()
# 3. Vérifier la valeur du tag
# La valeur doit correspondre EXACTEMENT au clusterName de votre provider

Le CCM ne trouve pas la VM. Le Cloud Controller Manager est le composant qui relie un objet Node à sa machine réelle ; tant qu'il n'a pas fait ce rapprochement, il laisse un taint sur le nœud et aucun pod applicatif n'y est planifié. Il s'appuie sur le ProviderID et sur un tag dont le nom varie d'un fournisseur à l'autre.

Fenêtre de terminal
# 1. Logs du CCM
kubectl logs -n kube-system -l app=<cloud>-cloud-controller-manager | grep "not found"
# 2. Vérifier le tag attendu par le CCM
# Consultez la doc de votre CCM pour savoir quel tag il attend
# 3. Le hostname doit correspondre au node name
kubectl get nodes -o jsonpath='{.items[*].metadata.name}'

Retour d'expérience : ce que j'aurais aimé savoir

Section intitulée « Retour d'expérience : ce que j'aurais aimé savoir »

Après plusieurs jours de développement sur ce provider, voici les leçons que j'en tire.

J'avais estimé l'interface CloudProvider basique à une semaine. Il m'en a fallu deux, les edge cases de List() et Delete() m'ont piégé plusieurs fois.

L'intégration avec le CCM devait prendre 2 jours. Elle a pris 2 semaines. Le format du providerID, les tags attendus, la correspondance avec le hostname... chaque détail qui ne colle pas = un node qui ne s'initialise jamais.

Le plus long ? Le debug "nodes qui disparaissent". Je n'avais rien prévu pour ça. Trois semaines à chercher qui supprimait mes nodes. J'ai dû forker trois projets (Karpenter, CCM, Talos) pour ajouter des logs à chaque point de décision shutdown/terminate.

La stabilisation production (retry, backoff, race conditions) : estimée à une semaine, réalisée en un mois.

Total : 4× plus long que prévu. Prévoyez large.

Trois constats reviennent, et ils ont un point commun : ce sont des contrats implicites, jamais écrits dans la documentation, que seul le code du provider AWS révèle.

  • La documentation Karpenter est excellente... pour AWS. Pour un autre cloud, vous devez lire le code source du provider AWS ligne par ligne. J'ai passé des heures dans github.com/aws/karpenter-provider-aws pour comprendre les contrats implicites.
  • Le GC Controller est impitoyable. Il n'y a pas de "mode dégradé". Si List() retourne une erreur ou une liste vide, vos nodes sont supprimés. Point. C'est un choix de design volontaire (fail-fast), mais ça rend le debug très stressant.
  • Les logs par défaut sont insuffisants. Quand un node disparaît, vous voyez "NodeClaim deleted" dans les logs Karpenter. Mais vous ne savez pas si c'est le GC, la consolidation, le drift, ou une action manuelle. J'ai ajouté des logs customs à chaque point de décision.

L'interface CloudProvider n'oblige pas à tout implémenter avec la même profondeur : plusieurs fonctionnalités peuvent être réduites au minimum sans empêcher le provisionnement de fonctionner. Voici celles que j'ai volontairement laissées de côté, et ce que cela coûte.

  1. Pas de spot instances : Mon cloud n'a pas d'équivalent. J'ai retiré toute la logique CapacityTypeSpot.
  2. Catalogue d'instances statique : Au lieu d'appeler l'API pour lister les types de VM disponibles, j'ai un fichier YAML statique. Plus simple, moins de maintenance.
  3. Pas de drift sur les security groups : Trop complexe à implémenter. Le drift ne détecte que les changements d'image.

Ces quatre décisions visent le même objectif : rendre les erreurs réversibles pendant la phase d'apprentissage. Tant que la consolidation reste active et que le code agit pour de vrai sur un cluster partagé, la moindre méprise dans List() détruit votre environnement de travail.

  • Commencer par un provider en mode "dry-run".
  • Ajouter des logs dès le début : Pas après avoir perdu 3 jours sur un bug.
  • Tester avec consolidateAfter: Never : Désactiver la consolidation pendant le développement.
  • Un cluster de test dédié : J'ai cassé mon cluster de dev plusieurs fois.

Deux sources suffisent à couvrir la totalité du sujet, et elles se complètent : la référence Go décrit les signatures que vous devez respecter, le code du provider AWS montre les comportements attendus que cette référence ne dit pas. La documentation de votre propre cloud fournit le reste, à savoir les paramètres d'appel de son API.

La page pkg.go.dev donne la définition exacte de l'interface pour la version que vous importez : consultez-la en priorité, une signature change d'une version mineure à l'autre.

Consultez la documentation API de votre cloud pour :

  • Création de VMs : paramètres, tags, user-data
  • Listing avec filtres : filtrage par tags pour List()
  • Logs de console : pour debugger le boot de l'OS
  • CCM : quel tag le Cloud Controller Manager attend

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn