Kubebuilder : créer facilement un opérateur Kubernetes

9 Octobre 2023 • 12 min

Dans cet article, je vais vous présenter comment utiliser kubebuilder pour créer facilement un opérateur Kubernetes.

Après un rapide rappel sur kubebuilder, nous créerons ensemble pas à pas un opérateur k8s (en prenant l’exemple de l’opérateur kube-image-keeper, outil open source de caching d’images au sein du cluster K8s). Enfin nous reviendrons sur les avantages et les limitations de kubebuilder.

Kubebuilder, c’est quoi ?

kubebuilder, un framework de création d’opérateur K8s

Kubebuilder est un framework qui simplifie la création des opérateurs Kubernetes. Il permet de générer le boilerplate d’un projet directement depuis son terminal en créant Makefile, Dockerfile et les fichiers sources de base tels que le main.go. Une fois le projet créé, il est possible de générer les fichiers nécessaires à la mise en place de Custom Resource Definitions (CRDs), des contrôleurs associés et des webhooks.

Les librairies controller-runtime et controller-tools

Kubebuilder est basé sur les librairies controller-runtime et controller-tools. Il est tout à fait possible d’écrire un opérateur en utilisant seulement ces librairies sans l’aide de kubebuilder. Cependant, kubebuilder facilite le travail en générant des fichiers et du code qui auraient dû être créés et écrits manuellement.

Kubebuilder, par l’exemple

Penchons-nous sur un exemple concret d’utilisation de kubebuilder avec la création de l’opérateur Kubernetes kube-image-keeper (kuik).

Le projet Open Source kuik

Kuik est un opérateur permettant la mise en cache d’images de containers au sein d’un cluster Kubernetes. Il est très utile par exemple en cas d’indisponibilité des registries d’images de containers ou pour éviter de dépasser votre quota de requêtes sur la registry (et donc de payer !).

Comme le projet est open source, vous pouvez consulter l’architecture complète d’un projet créé avec kubebuilder en vous rendant sur le repository GitHub public de kuik.

Revenons à l’architecture du projet. Pour faire court, il est composé de deux contrôleurs, d’un mutating webhook et d’une CRD comme le montre le schéma ci-dessous :

Schéma d’architecture fonctionnel du projet kube-image-keeper

  • Tout d’abord, le webhook réécrit l’URL des images des Pods, afin de les faire pointer vers la registry de cache
  • Ensuite, les deux contrôleurs “watchent” les Pods et les CachedImages : le premier crée des CachedImages en fonction des images utilisées par les Pods, et le deuxième met en cache les images demandées via les CachedImages
  • Lorsque le kubelet demande au container runtime (containerd par exemple) de lancer les containers, ce dernier récupère les images en passant par le proxy étant donné que les images ont été réécrites pour pointer vers celui-ci
  • Enfin, le proxy enverra la requête de pull d’image au registry de cache ou à celui d’origine en fonction de si l’image est déjà présente en cache ou non.

Créer le projet de base avec kubebuilder init

Maintenant, tous à vos terminaux ! On va générer le scaffolding (= le boilerplate) de base de notre projet kubebuilder. Pour cela, rien de plus simple, dans un dossier vide, on va exécuter la commande suivante :

kubebuilder init \
    --domain kuik.enix.io \
    --repo github.com/enix/kube-image-keeper

Le “domain“ correspond à l’api group des ressources que l’on va créer, et le “repo” au repository git où se trouve le projet.

Kubebuilder crée à ce moment plusieurs fichiers et dossiers, notamment :

  • config : un dossier contenant les manifests kustomize du projet
  • Dockerfile : un Dockerfile distroless
  • hack : un dossier qui contient le boilerplate des fichiers sources (utile pour insérer une licence en haut de chaque fichier source)
  • main.go : un main prérempli qui permet de tout de suite lancer un opérateur “no-op” fonctionnel.
  • Makefile : ce makefile permet de lancer le controller, lancer les tests ou encore générer les manifests yaml des CRDs.
  • PROJECT : un fichier qui décrit le projet kubebuilder et ses différents composants.

Générer des contrôleurs avec kubebuilder create api

Maintenant que le projet de base a été généré, on peut passer aux choses sérieuses et commencer à créer nos contrôleurs.

Le contrôleur des CachedImages

On va commencer par créer le CachedImageController, son rôle sera de watch les CachedImage, et de mettre en cache les images référencées ou de les supprimer du cache lors de la suppression d’une CachedImage :

kubebuilder create api \
    --kind CachedImage \
    --version v1alpha1 \
    --namespaced=false \
    --resource=true \
    --controller=true

Les flags --resource et --controller permettent de demander à kubebuilder de générer la CRD et le contrôleur correspondant au kind spécifié. Autrement, le choix sera fait interactivement. Le flag --namespaced=false indique que l’on veut que les CachedImages ne soient pas namespacées.

Le contrôleur des Pods

Pour créer le squelette du contrôleur qui se chargera de watch les Pods dans le but de créer les CachedImages, on va utiliser une commande similaire mais en demandant à kubebuilder de ne pas créer de CRD (les Pods sont déjà des ressources natives de Kubernetes) :

kubebuilder create api \
    --group core \
    --kind Pod \
    --version v1 \
    --resource=false \
    --controller=true

Après avoir lancé ces deux commandes, on se retrouve avec plusieurs nouveaux fichiers. On trouve notamment dans le dossier controllers les deux fichiers cachedimage_controller.go et pod_controller.go ainsi que le fichier suite_test.go qui contient de quoi rapidement mettre en place des tests d’intégration. Le type CachedImage quant à lui est défini dans le fichier api/v1alpha1/cachedimage_types.go. On remarque que le fichier main.go a été mis à jour pour lancer nos deux nouveaux contrôleurs. Enfin, le fichier PROJECT inclut désormais les informations sur les ressources et les contrôleurs mis en place.

Pour illustrer, le fichier pod_controller.go ressemble à ceci :

package controllers

import (
	"context"

	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/runtime"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/log"
)

// PodReconciler reconciles a Pod object
type PodReconciler struct {
	client.Client
	Scheme *runtime.Scheme
}

//+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=pods/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=core,resources=pods/finalizers,verbs=update
func (r *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	_ = log.FromContext(ctx)

	// TODO(user): your logic here

	return ctrl.Result{}, nil
}

// SetupWithManager sets up the controller with the Manager.
func (r *PodReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&corev1.Pod{}).
		Complete(r)
}

La fonction Reconcile définit la boucle de réconciliation. C’est ici qu’est implémentée la logique du contrôleur.

Générer les fichiers relatifs à un CRD

Une fois les contrôleurs implémentés et le type CachedImage défini, il reste encore à générer le manifest yaml de notre CRD afin de pouvoir l’installer dans notre cluster Kubernetes.

Les annotations

Si vous ouvrez le fichier cachedimage_types.go, vous pouvez voir la définition d’une CachedImage qui ressemblera à peu près à ceci (le fichier complet est un peu plus long, mais cette partie est la plus intéressante pour vous) :

type CachedImageSpec struct {
	Foo string `json:"foo,omitempty"`
}

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
//+kubebuilder:resource:scope=Cluster
type CachedImage struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   CachedImageSpec   `json:"spec,omitempty"`
	Status CachedImageStatus `json:"status,omitempty"`
}

En plus des structures, on peut voir différentes annotations. Ces annotations permettent de décrire comment générer la CRD correspondante. Par exemple +kubebuilder:resource:scope=Cluster permet de dire que les CachedImages ne sont pas namespacées. D’autres annotations sont disponibles, par exemple on peut rajouter des colonnes à afficher lors d’un kubectl get grâce à l’annotation +kubebuilder:printcolumn:name="Foo",type="string",JSONPath=".spec.foo". Dans ce cas précis on ajoute la colonne “Foo” qui affichera la valeur .spec.foo de nos CachedImages.

Les commandes make generate et make manifest

Pour générer les fichiers relatifs à notre CRD, on dispose de deux commandes.

La première, make generate, est automatiquement exécutée lorsque l’on appelle la commande kubebuilder create api et crée le fichier api/v1alpha1/zz_generated.deepcopy.go qui implémente certaines fonctions essentielles au fonctionnement de l’opérateur, telles que la fonction CachedImage.DeepCopy par exemple.

La seconde commande est celle qui nous intéresse le plus : make manifests. Elle génère le fichier config/crd/bases/kuik.enix.io_cachedimages.yaml contenant la définition de la CRD que l’on pourra ensuite installer dans notre cluster Kubernetes.

Générer un webhook avec kubebuilder create webhook

On a créé nos deux contrôleurs et notre CRD, il ne nous reste plus qu’à créer notre mutating webhook.

Pour ce faire, on utilise normalement la commande kubebuilder create webhook. Nous avons un petit souci cependant, kubebuilder ne supporte pas la création de webhook pour les “core types” et le webhook que nous voulons créer est justement destiné aux Pods. On suit donc les instructions de la documentation officielle de kubebuilder pour créer nous-même le boilerplate de cette partie en utilisant la librairie controller-runtime.

Dans le cas où vous voudriez créer un webhook pour votre propre custom ressource, c’est aussi simple que pour créer un contrôleur :

kubebuilder create webhook \
    --kind CachedImage \
    --version v1alpha1 \
    --defaulting

Vous avez le choix entre les flags --conversion, --defaulting et --programmatic-validation pour créer différents types de webhook. Au moins l’un de ces 3 flags devra être renseigné, correspondant aux fonctionnalitées suivantes :

Lancer les tests avec make test

Après avoir mis en place les différentes parties de notre contrôleur, la prochaine étape est évidemment de vérifier que tout fonctionne correctement. Pour cela, on implémente les tests requis, notamment les tests d’intégration dans le fichier suite_test.go.

Pour lancer les tests, on utilisera la commande make test qui se charge de télécharger les dépendances des tests et de les lancer. On peut utiliser la variable d’environnement ENVTEST_K8S_VERSION pour choisir avec quelle version de l’API Kubernetes lancer les tests d’intégration, par exemple ENVTEST_K8S_VERSION=1.25. Attention cependant, seule une partie du control-plane sera lancée, c’est-à-dire l’API server et etcd, mais les différents contrôleurs autres que ceux que nous avons créés ne seront pas exécutés. Le comportement peut donc être légèrement différent de celui attendu. Vous trouverez plus de détails ici dans la documentation.

Lancer l’opérateur en local

Pour faciliter le développement, kubebuilder permet de lancer facilement son opérateur en local sur sa machine via la commande make run. Il est évidemment nécessaire d’être connecté au cluster Kubernetes sur lequel on souhaite opérer.

Cependant, lorsque l’opérateur que l’on souhaite lancer comporte un webhook, quelques précautions sont nécessaires. En effet, le webhook a besoin d’un certificat pour pouvoir se lancer (celui renseigné dans le MutatingWebhookConfiguration correspondant) et les requêtes faites au webhook doivent être envoyées vers l’extérieur du cluster. On passera dans ce cas par une URL plutôt que par un service Kubernetes dans la configuration du webhook (MutatingWebhookConfiguration.webhooks.clientConfig). La documentation conseille donc de désactiver les webhooks via la variable d’environnement ENABLE_WEBHOOKS=false avant de tester son code en local.

Kubebuilder : avantages et limitations

Maintenant que nous avons vu l’utilisation de kubebuilder pour créer un opérateur Kubernetes, revenons sur ses avantages et ses limitations:

Avantages de Kubebuilder

  • Rapidité de création de l’opérateur K8s : lancement très rapide d’un projet
  • Création et mise à jour des CRDs largement facilités
  • Génération du scaffolding pour faire des tests d’intégration : en même temps que les tests, l’outil exécute une API Kubernetes et un etcd (mais pas les contrôleurs du control plane) avec la version demandée. Ceci s’avère très pratique pour des matrices de tests dans github.
  • kubebuilder est plus simple que Operator SDK (lui-même basé sur kubebuilder).

Limitations de Kubebuilder

  • Mise à jour de kubebuilder fastidieuse et manuelle : Lorsque l’on veut utiliser une version plus récente de Kubebuilder (et donc mettre à jour le code généré pour profiter des mises à jour des versions plus récentes), il n’y a pas d’instruction précise sur les modifications à faire dans les fichiers déjà générés. Simplement mettre à jour kubebuilder est inutile puisque les fichiers créés précédemment ne seront pas modifiés et resteront dans leur version originale. Pour appliquer les améliorations proposées par une nouvelle version de Kubebuilder, le plus simple est de complètement re-générer son projet dans un nouveau dossier séparé, puis répercuter les changements du scaffolding dans son projet initial. Cette tâche peut s’avérer pénible. Un design de solution a été proposé afin de faciliter ce processus, mais il n’a pas encore été implémenté au moment de la rédaction de cet article..
  • Support natif de Kustomize mais pas de Helm : Le projet génère automatiquement des manifests Kustomize sans laisser le choix à l’utilisateur. Si ce dernier compte déployer son application via Helm, il devra écrire les manifestes Helm lui-même.
  • Nomenclature non standard : les dossiers controller et api devraient se trouver dans les dossiers pkg ou internal.
  • Dépendance entre les versions de kubebuilder, Kubernetes et golang : il peut être difficile de mettre à jour l’un de ces trois éléments sans avoir besoin de mettre à jour également les autres. Par exemple, la mise à jour de la version de golang peut nécessiter la mise à jour de la version de Kubernetes et ne plus supporter certaines anciennes versions. Une version de kubebuilder est prévue pour fonctionner avec une certaine version de Kubernetes, et ainsi de suite.
  • Utilisation de dependabot (bot GitHub de mise à jour automatique des dépendances) : il faut demander à dependabot d’ignorer certaines dépendances gérées par kubebuilder puisque les mettre à jour peut poser problème. Comme expliqué ci-dessus, les versions de kubebuilder, Kubernetes et golang sont étroitement liées et doivent généralement être mises à jour avec coordination.
  • Binaires dans le dossier gitignore : le makefile installe des binaires dans un dossier ignoré par git. Lors d’une mise à jour du projet, il faut penser à les supprimer pour que kubebuilder puisse les réinstaller. En cas d’oubli, on peut facilement se retrouver avec des binaires avec une version décorrélée de celle de kubebuilder et un projet qui ne fonctionne plus.
  • Scaffolding des core types : kubebuilder n’est pas capable de générer le scaffolding des core types tels que les Pods par exemple, l’opération reste donc manuelle. Dans l’exemple ci-dessus du projet kube-image-keeper, on a utilisé un mutating webhook sur les Pods. Kubebuilder n’était pas capable de générer les fichiers nécessaires à la création de ce webhook et nous avons dû les créer manuellement.

Notre avis sur kubebuilder

Kubebuilder peut grandement faciliter l’écriture de vos opérateurs Kubernetes. Il nous semble particulièrement utile au lancement d’un nouveau projet. Suivant l’opérateur, il présente cependant des limitations qui peuvent complexifier son utilisation et forcer à diverger du fonctionnement et de l’organisation des fichiers initialement proposés par l’outil. Dans ce cas, il faudra évaluer l’intérêt de continuer de l’utiliser ou de poursuivre les mises à jour de l’opérateur de façon purement manuelle.

Avant de nous quitter, si vous êtes fan de Kubernetes, je vous invite à lire notre article “Améliorer la disponibilité et le caching des images de conteneurs grâce à kube-image-keeper”. Il décrit les fonctionnalités et l’intérêt de cet outil que nous avons souhaité rendre open source pour que la communauté Kubernetes puisse en bénéficier. Tous vos retours seront les bienvenus !


Ne ratez pas nos prochains articles DevOps et Cloud Native! Suivez Enix sur Twitter!