DEV Community

Cover image for Comment créer un operator Kubernetes ?
Maxime Guilbert
Maxime Guilbert

Posted on

Comment créer un operator Kubernetes ?

Dans la première partie de cette série concernant le pattern Operator, on a vu qu'est-ce que c'était et dans quels cas il peut se révéler grandement utile dans un objectif d'automatisation complète.

Aujourd'hui, on va voir ensemble comment créer un opérateur!


Outils de création

Comme toute solution open-source, dès qu'il y a besoin d'un outil pour faire quelque chose, vous pouvez en trouver tout une liste d'outils avec leurs spécificités pour pouvoir générer ce dont vous avez besoin.

Si vous êtes curieux, vous pouvez aller voir dans la documentation de Kubernetes pour trouver toute une liste d'outils disponible.

Dans cette série, nous allons utiliser Operator Framework et KubeBuilder.

Operator Framework

Petit mot concernant Operator Framework, aujourd'hui on va utiliser son SDK pour Go, mais il est aussi disponible pour Ansible et Helm.


Setup

Homebrew

Si vous utilisez Homebrew, vous pouvez installer le SDK d'Operator Framework avec la commande suivante



brew install operator-sdk


Enter fullscreen mode Exit fullscreen mode

Depuis Github Release



# Définir les informations de la plateforme
export ARCH=$(case $(uname -m) in x86_64) echo -n amd64 ;; aarch64) echo -n arm64 ;; *) echo -n $(uname -m) ;; esac)
export OS=$(uname | awk '{print tolower($0)}')

# Télécharger le binaire pour votre plateforme
export OPERATOR_SDK_DL_URL=https://github.com/operator-framework/operator-sdk/releases/download/v1.28.0
curl -LO ${OPERATOR_SDK_DL_URL}/operator-sdk_${OS}_${ARCH}

# Installer le binaire 
chmod +x operator-sdk_${OS}_${ARCH} && sudo mv operator-sdk_${OS}_${ARCH} /usr/local/bin/operator-sdk


Enter fullscreen mode Exit fullscreen mode

Création d'un premier opérateur

Initialisation du projet

La première chose à faire est d'initialiser votre projet avec la commande suivante

operator-sdk init --domain [VOTRE DOMAINE] --repo [VOTRE CODE REPOSITORY]

Exemple



operator-sdk init --domain adaendra.org --repo github.com/adaendra/test-operator


Enter fullscreen mode Exit fullscreen mode

Cela va vous générer une structure ressemblant à ça

Image description

Vous pourrez y trouver quelques fichiers génériques, mais surtout pas mal de choses communes à tous les opérateurs (comme le Makefile, ou le Dockerfile), mais aussi l'initialisation du projet en go avec main.go.

Note : Par défaut votre opérateur est configuré pour pouvoir observer des ressources dans n'importe quel namespace.
Du coup, si vous voulez limiter sa visibilité à un namespace, vous pouvez ajouter l'option Namespace dans la déclaration de votre manager.
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{Namespace: "dummy_ns"})

Pour plus d'information sur le scope d'un opérateur, regarder cette documentation du SDK

Création d'une API et d'un Controlleur

Dans les cas d'utilisations génériques d'un opérateur, on va vouloir créer une ressource personnalisée (ou CRD - Custom Resource Definition), qui va nous servir d'entité référence pour faire nos actions.

Par exemple, dans ce tutoriel on va créer une ressource MyProxy dans le groupe gateway qui, pour chacune de ses instances, va créer un déploiement NGINX.

Commande pour générer le code



operator-sdk create api --group gateway --version v1alpha1 --kind MyProxy --resource --controller


Enter fullscreen mode Exit fullscreen mode

Une fois cette commande exécutée, vous allez pouvoir découvrir deux nouveaux dossiers : api et controllers.

API

Dans ce dossier, le seul fichier qui va nous intéresser est myproxy_types.go. En effet, c'est dans ce fichier qu'on va pouvoir définir les ressources que l'on attend dans notre Spec, mais c'est aussi ici qu'on va pouvoir définir la structure du Status de la ressource!

Pour notre exemple, on va juste définir un champ Name dans la Spec.



type MyProxySpec struct {  
   Name string `json:"name,omitempty"`  
}


Enter fullscreen mode Exit fullscreen mode

Important !! Ce fichier sert de base pour construire un certain nombre de fichiers yaml pour votre opérateur. Du coup, après chaque modification de ce fichier, n'oubliez pas d'exécuter les commandes :
make manifests et make generate

Controller

Dans ce dossier, vous allez trouver l'ensemble des contrôleurs associés à vos ressources personnalisées, comme myproxy_controller.go que l'on a généré plus tôt. Ce dossier est la place centrale concernant les opérations que peut faire votre opérateur.

Dans chacun des fichiers, vous pourrez y trouver deux choses à adapter. Les méthodes Reconcile et SetupWithManager

SetupWithManager


// SetupWithManager sets up the controller with the Manager.
func (r *MyProxyReconciler) SetupWithManager(mgr ctrl.Manager) error {  
   return ctrl.NewControllerManagedBy(mgr).  
      For(&gatewayv1alpha1.MyProxy{}).  
      Owns(&appsv1.Deployment{}).  
      Complete(r)  
}


Enter fullscreen mode Exit fullscreen mode

Dans cet exemple (qui est notre implémentation), on peut voir :

ctrl.NewControllerManagedBy(mgr) qui permet de créer un nouveau contrôleur avec les options de base. (C'est ici que vous pourrez jouer si vous voulez personnaliser les options de votre contrôleur)

For(&gatewayv1alpha1.MyProxy{}) pour déclarer que l'on va agir dès qu'un évènement d'ajout/modification/suppression se produit sur une ressource de type MyProxy. Vous pouvez utiliser cette déclaration pour chacun des types de ressource que vous souhaitez surveiller. (Utile par exemple si vous voulez exposer dynamiquement tous les déploiements au travers d'un Nginx.)

Owns(&appsv1.Deployment{}) très similaire à For, il va aussi permettre de déclarer que l'on veut activer la réconciliation dès qu'un évènement d'ajout/modification/suppression se produit sur un Deployment. La différence est qu'il va ajouter un filtre. En effet, la réconciliation ne sera activée que si le déploiement appartient à l'opérateur.

Reconcile

Cette fonction est donc le traîtement qui sera exécuté à chaque fois qu'une reconciliation sera déclanchée (par le déclanchement d'un évènement Add/Update/Delete sur une des ressources que vous surveillez).

Mais avant d'aller dans le coeur de la fonction, il y a quelque chose d'important à regarder avant. En effet, au dessus de la méthode, vous pouvez y voir des commentaires commençant par //+kubebuilder. Ces commentaires permettent de définir les droits de votre opérateur!

Du coup, c'est pas mal important a bien définir, et en l'occurrence, il faut qu'on donne des droits à notre opérateur pour qu'il puisse créer les ressources Deployment dont on a besoin.

Chaque commentaire est défini avec le modèle suivant :

// +kubebuiler:rbac:groups=[nom du groupe de la ressource],resources=[nom des ressources],verbs=[verbes]

Le champ groups ne peut recevoir qu'une seule valeur à la fois, mais resources et verbs peuvent accueilllir des listes de valeurs, où toutes les valeurs sont séparées par ;.



// +kubebuilder:rbac:groups=gateway.adaendra.org,resources=myproxies,verbs=get;list;watch;create;update;patch;delete  
// +kubebuilder:rbac:groups=gateway.adaendra.org,resources=myproxies/status,verbs=get;update;patch  
// +kubebuilder:rbac:groups=gateway.adaendra.org,resources=myproxies/finalizers,verbs=update  
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete  


Enter fullscreen mode Exit fullscreen mode

A présent, on peut rentrer dans le vif du sujet, le coeur de la méthode. Donc comme dit plus tôt, cette fonction sera appelée à chaque réconciliation. Par conséquent, il faut faire attention à ce que l'on définit ici!

En effet, on veut certes créer les ressources dont on a besoin, mais il faut qu'on s'assure qu'elles n'existent pas déjà avant! Et si elle existe, il faut vérifier que les configurations que l'on veut soient toujours là!

1. Récupérer notre ressource personnalisée

Avant d'aller plus loin, nous devons récupérer notre ressource personnalisée afin de pouvoir récupérer ses specs et pour pouvoir mettre à jour sont statut (si on a des informations à y stocker).



    // Récupération de la ressource
    myProxy := &gatewayv1alpha1.MyProxy{}
    err := r.Get(ctx, req.NamespacedName, myProxy)

    if err != nil {
        // Si une erreur est trouvée et que la ressource est introuvable : on ignore
        if errors.IsNotFound(err) {
            log.Info("Ressource non trouvée. Erreur ignorée car l'objet doit avoir été supprimé.")
            return ctrl.Result{}, nil
        }

        // Si c'est une autre erreur, on la retourne
        log.Error(err, "Erreur lors de la récupération de MyProxy")
        return ctrl.Result{}, err
    }


Enter fullscreen mode Exit fullscreen mode
2. Récupérer l'instance de la ressource gérée par l'opérateur

Maintenant qu'on a la ressource "parent", on va pouvoir aller chercher les ressources "enfant". Ici, notre déploiement doit avoir le nom donné dans la spécification de MyProxy et doit être dans le namespace test_ns.



found := &appsv1.Deployment{}  
err = r.Get(ctx, types.NamespacedName{Name: myProxy.Spec.Name, Namespace: "test_ns"}, found)


Enter fullscreen mode Exit fullscreen mode
3. Vérification si la ressource existe

L'étape suivante consiste de vérifier ce que l'on a reçu. En effet la variable err peut contenir aucune erreur, tout comme elle peut contenir une erreur disant que le déploiement n'a pas été trouvé. Dans chacun de ces cas, on veut réagir différemment.

Par conséquent, on va implémenter ça et voici ce que ça donne



if err != nil && errors.IsNotFound(err) {  
   // Define a new deployment  
   dep := r.deploymentForExample(myProxy)  
   log.Info("Creating a new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)  
   err = r.Create(ctx, dep)  
   if err != nil {  
      log.Error(err, "Failed to create new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)  
      return ctrl.Result{}, err  
   }  
   // Deployment created successfully - return and requeue  
   return ctrl.Result{Requeue: true}, nil  
} else if err != nil {  
   log.Error(err, "Failed to get Deployment")  
   return ctrl.Result{}, err  
}


Enter fullscreen mode Exit fullscreen mode

On peut voir que si on a une erreur et qu'elle concerne le fait que la ressource n'a pas été trouvée, alors on va chercher à créer la ressource.

Si on reçoit n'importe quel autre type d'erreur, on va alors directement retourner cette erreur.

Ici un exemple pas très poussé de deploymentForExample



func (r *MyProxyReconciler) deploymentForExample(myproxy *gatewayv1alpha1.MyProxy) *appsv1.Deployment {  
   dep := &appsv1.Deployment{}  

   dep.Namespace = "test_ns"  
   dep.Name = myproxy.Spec.Name  

   var replicas int32 = 2  

   labels := map[string]string{  
      "test_label": myproxy.Spec.Name,  
   }  

   dep.Spec = appsv1.DeploymentSpec{  
      Replicas: &replicas,  
      Template: corev1.PodTemplateSpec{  
         Spec: corev1.PodSpec{  
            Containers: []corev1.Container{  
               {  
                  Name:  "nginx",  
                  Image: "nginx",  
               },  
            },  
         },  
      },  
   }  
   dep.Labels = labels  
   dep.Spec.Template.Labels = labels  

   return dep  
}


Enter fullscreen mode Exit fullscreen mode
4. Mise à jour de la ressource

Si à l'étape précédente on n'a reçu aucune erreur, c'est que la récupération c'est bien passée. Du coup, on peut s'occuper de la vérification des paramètres de notre déploiement et le mettre à jour si besoin.



var size int32 = 2  
if *found.Spec.Replicas != size {  
   found.Spec.Replicas = &size  
   err = r.Update(ctx, found)  
   if err != nil {  
      log.Error(err, "Failed to update Deployment", "Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name)  
      return ctrl.Result{}, err  
   }  
   // Spec updated - return and requeue  
   return ctrl.Result{Requeue: true}, nil  
}


Enter fullscreen mode Exit fullscreen mode

Dans l'exemple, on va vérifier que son nombre de pods est bien toujours à 2. Si ça n'est pas le cas, on tente de le mettre à jour et on gère l'erreur si il y en a une.

Mise à jour des fichiers générés

Après avoir modifié votre contrôleur, il est important d'exécuter la commande suivante



make manifests


Enter fullscreen mode Exit fullscreen mode

On a vu plus tôt qu'il y avait des commentaires qui permettent de définir les droits RBAC pour votre contrôleur. Exécuter cette commande va permettre, entre autre, de générer les fichiers RBAC pour votre contrôleur.

Build de l'opérateur

Maintenant que votre opérateur est prêt à être utilisé, on peut le build afin de le déployer.

Préparation du build

Par défaut, l'image que vous allez construire va s'appeler controller:latest et sera poussée dans example.com/tmpoperator, ce qui, vous vous en doutez, peut causer des problèmes.

Du coup, si vous voulez mettre à jour ces informations, il faut :

  • changer les variables IMG et IMAGE_TAG_BASE dans Makefile
  • et changer le nom de l'image dans config/manager/manager.yaml

Build

Pour le build exécutez la commande



make docker-build


Enter fullscreen mode Exit fullscreen mode

Et si vous souhaitez pousser votre image



make docker-push


Enter fullscreen mode Exit fullscreen mode

Déploiement

Pour le déploiement, il y a deux commandes à exécuter



make install


Enter fullscreen mode Exit fullscreen mode

pour déployer les CRDs de votre opérateur



make deploy


Enter fullscreen mode Exit fullscreen mode

pour déployer le déploiement de votre opérateur.

Test

A partir de là, vous pouvez tenter de déployer une instance de MyProxy et vous devriez voir ensuite un déploiement de Nginx apparaître.

Exemple de définition de ressource MyProxy



apiVersion: gateway.example.com/v1alpha1  
kind: MyProxy  
metadata:  
  labels:  
    app.kubernetes.io/name: myproxy  
    app.kubernetes.io/instance: myproxy-sample  
    app.kubernetes.io/part-of: tmpoperator  
    app.kubernetes.io/managed-by: kustomize  
    app.kubernetes.io/created-by: tmpoperator  
  name: myproxy-sample  
spec:  
  name: toto


Enter fullscreen mode Exit fullscreen mode

Cette partie a été longue, mais nécessaire pour avoir une première vue de comment créer un opérateur et pour voir ce que l'on peut faire dedans.

Dans la prochaine partie de cette série, on va voir quelques configurations et fonctionnalités un plus avancées pour rendre votre opérateur encore plus efficace!

J'espère que ça vous aidera et si jamais vous avez des questions, quelles qu'elles soient (Il n'y a jamais de questions bêtes!) ou des points qui ne sont pas clairs pour vous, n'hésitez pas à laisser un message dans les commentaires ou à me joindre directement sur LinkedIn (même pour parler d'autres sujets!).


Vous voulez me supporter?

Buy Me A Coffee

Top comments (0)