Comment créer un operator Kubernetes ?
Tags: kubernetes, devops, sre, french
Published: 2023-06-12 13:00:00

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**](https://kubernetes.io/docs/concepts/extend-kubernetes/operator/#writing-operator) pour trouver toute une liste d'outils disponible.

Dans cette série, nous allons utiliser [**Operator Framework**](https://operatorframework.io/) et [**KubeBuilder**](https://book.kubebuilder.io/).

### 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
```bash
brew install operator-sdk
```

### Depuis Github Release
```bash
# 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
```

---

## 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*
```bash
operator-sdk init --domain adaendra.org --repo github.com/adaendra/test-operator
```
Cela va vous générer une structure ressemblant à ça 

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2pg1j9ir2b345a652h8t.png)

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](https://sdk.operatorframework.io/docs/building-operators/golang/operator-scope/)*


### 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
```console
operator-sdk create api --group gateway --version v1alpha1 --kind MyProxy --resource --controller
```
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.
```go
type MyProxySpec struct {  
   Name string `json:"name,omitempty"`  
}
```

> **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
```go
// 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)  
}
```
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 `;`.

```go
// +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  
```

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)*.

```go
    // 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
    }
```

###### 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`.

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

###### 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

```go
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  
}
```

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`*
```go
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  
}
```


###### 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.

```go
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  
}
```

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
```bash
make manifests
```

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 
```bash
make docker-build
```

Et si vous souhaitez pousser votre image 
```bash
make docker-push
```

#### Déploiement
Pour le déploiement,  il y a deux commandes à exécuter
```bash
make install
```
pour **déployer les CRDs de votre opérateur**

```bash
make deploy
```
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*
```yaml
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
```

---

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](https://www.linkedin.com/in/maxime-guilbert-14b6a5128/) *(même pour parler d'autres sujets!)*.

---

<center><h3>Vous voulez me supporter?</h3></center>
<a href="https://www.buymeacoffee.com/mxguilbert7" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174"></a>