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
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
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
Cela va vous générer une structure ressemblant à ça
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'optionNamespace
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
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"`
}
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
etmake 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)
}
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
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
}
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)
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
}
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
}
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
}
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
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
etIMAGE_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
Et si vous souhaitez pousser votre image
make docker-push
Déploiement
Pour le déploiement, il y a deux commandes à exécuter
make install
pour déployer les CRDs de votre opérateur
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
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 (même pour parler d'autres sujets!).
Top comments (0)