Créer un opérateur Kubernetes en Java pour gérer une instance Nginx.
💡 Mise à jour : suite à la release 2.x du SDK j’ai mis à jour l’article et le code 😉
Lors d’un précédent article j’ai jeté les bases de comment développer un opérateur Kubernetes en Java. Après la réalisation d’un merveilleux Hello World ! il me semblait utile d’illustrer un cas d’utilisation plus réel et de commencer à faire des actions utiles pour un Ops dans sa vie de tous les jours.
Je ne m’étendrai donc pas sur le comment démarrer un projet Java pour écrire un opérateur et comment le packager, pour cela je vous laisse vous replonger dans l’article précédent abordant ces sujets.
Un Ops dans mon cluster 👷♂️ ?
Lors de mon précédent article j’indiquais qu’il était possible de faire faire plus ou moins de choses à notre opérateur, le fameux modèle de maturité des opérateurs. C’est un niveau (de 1 à 5) qui indique si l’opérateur est capable de faire plus ou moins d’actions automatiques (de l’installation à bien plus).
Dans cet article je vais vous montrer deux aspects intéressants pour un tel opérateur :
- installer et désinstaller de manière simple / automatique un serveur HTTP Nginx,
- ajouter une surveillance ops qui permet de redéployer le serveur si on a supprimé (par erreur) le déploiement et donc le serveur Nginx.
Deux fonctionnalités très simples mais qui vont nous permettre de plus se projeter vers un cas concret d’un opérateur (mieux qu’avec un Hello World ! 😅).
Gérer l’installation d’un serveur HTTP Nginx 🛠️
La custom resource definition (CRD) 📝
On est habitué maintenant, avant toute chose, pour débuter notre opérateur on crée la custom resource definition (CRD) associée.
C’est maintenant une habitude, mais j’utilise le SDK java-operator-sdk pour me faciliter la vie pour initier / coder mon opérateur.
Commençons donc par les classes nécessaires pour générer le YAML de la CRD.
Notre CRD doit permettre la création d’une custom resource avec comme champ utile le nombre de replicas que l’on souhaite. Pour cela on définit la partie spec de la CRD:
1
2
3
4
5
6
7
8
9
10
11
12
public class NginxInstallerSpec {
private Integer replicas;
public Integer getReplicas() {
return replicas;
}
public void setReplicas(Integer replicas) {
this.replicas = replicas;
}
}
Il ne reste plus qu’à définir la CRD en elle même :
1
2
3
4
5
6
@Group("fr.wilda")
@Version("v1")
@ShortNames("ngi")
public class NginxInstallerResource extends CustomResource<NginxInstallerSpec, Void> implements Namespaced {
}
Un petit mvn clean compile
et la magie du projet fabric8 fait le reste :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: nginxinstallerresources.fr.wilda
spec:
group: fr.wilda
names:
kind: NginxInstallerResource
plural: nginxinstallerresources
shortNames:
- ngi
singular: nginxinstallerresource
scope: Namespaced
versions:
- name: v1
schema:
openAPIV3Schema:
properties:
spec:
properties:
replicas:
type: integer
type: object
status:
type: object
type: object
served: true
storage: true
Le contrôleur, l’âme de notre opérateur 🤖
Rentrons dans le vif du sujet et donnons de l’intelligence à notre opérateur. Cela se passe dans la classe définissant le contrôleur.
La structure du contrôleur reste simple :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@ControllerConfiguration
public class NginxInstallerReconciler implements Reconciler<NginxInstallerResource> {
// K8S API utility
private KubernetesClient k8sClient;
// Watcher to do some actions when events occurs
private Watch watch = null;
public NginxInstallerReconciler(KubernetesClient k8sClient) {
this.k8sClient = k8sClient;
}
@Override
public UpdateControl<NginxInstallerResource> reconcile(NginxInstallerResource resource, Context context) {
System.out.println("🛠️ Create / update Nginx resource operator ! 🛠️");
// ...
return UpdateControl.updateResource(resource);
}
@Override
public DeleteControl cleanup(NginxInstallerResource resource, Context context) {
System.out.println("💀 Delete Nginx resource operator ! 💀");
// ...
return DeleteControl.defaultDelete();
}
}
Pour mémoire, ce contrôleur réagit sur la création / modification / suppression d’une custom resource (basée sur la CRD définie précédemment). A titre d’exemple voici ce que cela donne pour la création d’une instance Nginx avec deux replicas dans le namespace test-nginx-operator:
1
2
3
4
5
6
7
apiVersion: "fr.wilda/v1"
kind: NginxInstallerResource
metadata:
name: nginx-installer
namespace: test-nginx-operator
spec:
replicas: 2
Avant de développer la partie gérant le déploiement, et pour se simplifier la vie, nous allons utiliser un deployment.yml pour le déploiement de Nginx. Il aurait été possible de tout faire en Java mais il y a peu d’intérêt ici.
Le deployment.yml pour notre Nginx :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
replicas: 1
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:stable-alpine
ports:
- containerPort: 80
Passons au code qui va gérer l’installation (en fait la création de la ressource de déploiement):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public UpdateControl<NginxInstallerResource> reconcile(NginxInstallerResource resource, Context context) {
System.out.println("🛠️ Create / update Nginx resource operator ! 🛠️");
String namespace = resource.getMetadata().getNamespace();
// Load the Nginx deployment
Deployment deployment = loadYaml(Deployment.class, "/k8s/nginx-deployment.yml");
// Apply the number of replicas
deployment.getSpec().setReplicas(resource.getSpec().getReplicas());
deployment.getMetadata().setNamespace(namespace);
// Create or update the modifications
k8sClient.apps().deployments().inNamespace(namespace).createOrReplace(deployment);
return UpdateControl.updateResource(resource);
}
Et enfin celui qui aura la charge de supprimer notre serveur HTTP :
1
2
3
4
5
6
7
8
9
@Override
public DeleteControl cleanup(NginxInstallerResource resource, Context context) {
System.out.println("💀 Delete Nginx resource operator ! 💀");
// Delete deployment and its PODs
k8sClient.apps().deployments().inNamespace(resource.getMetadata().getNamespace()).delete();
return DeleteControl.defaultDelete();
}
Simple non ?
Comme indiqué, j’ai choisi de partir d’un YAML plutôt que de tout renseigner à la main mais il est possible de tout faire via l’API, on a un exemple avec le positionnement du replica deployment.getSpec().setReplicas(resource.getSpec().getReplicas());
.
Passons au déploiement de notre opérateur et à son test.
Pour simplifier le test nous allons utiliser le mode ligne de commande : cela correspond à lancer notre opérateur directement depuis l’IDE ou un bash sur notre machine et non déployé comme POD dans Kubernetes. J’ai déjà abordé le déploiement d’un opérateur dans Kubernetes dans mon article précédent. J’y reviendrai tout de même en fin d’article car il y a quelques spécificités à prendre en compte.
Lançons notre opérateur et commençons à tester tout ça !
1
2
3
mvn exec:java -Dexec.mainClass=fr.wilda.NginxInstallerRunner
🚀 Starting NginxInstaller operator !!! 🚀
Pour tester son bon fonctionnement nous allons commencer par créer la CRD : kubectl apply -f ./target/classes/META-INF/fabric8/nginxinstallerresources.fr.wilda-v1.yml
.
Enfin il suffit ensuite de créer la CR basée sur la CRD : kubectl apply -f ./src/test/resources/test_nginx.yml -n test-nginx-operator
.
Vérifions ce qu’il se passe sur notre opérateur et dans notre cluster Kubernetes :
1
2
🚀 Starting NginxInstaller operator !!! 🚀
🛠️ Create / update Nginx resource operator ! 🛠️
1
2
3
4
5
kubectl get pods -n test-nginx-operator
NAME READY STATUS RESTARTS AGE
nginx-deployment-69c78cd8c6-bbhjj 1/1 Running 0 107s
nginx-deployment-69c78cd8c6-rz6xs 1/1 Running 0 11s
Plutôt cool non ? Nos deux PODs contenant un serveur Nginx ont bien été déployés comme demandé !
Et la suppression n’est pas plus compliquée : kubectl delete ngi/nginx-installer -n test-nginx-operator
1
2
3
🚀 Starting NginxInstaller operator !!! 🚀
🛠️ Create / update Nginx resource operator ! 🛠️
💀 Delete Nginx resource operator ! 💀
1
2
3
kubectl get pods -n test-nginx-operator
No resources found in test-nginx-operator namespace.
A ce stade de notre développement résumons ce que l’on a :
- une custom resource définition (CRD) définissant un ressource permettant la création d’une custom resource (CR) qui définit les informations minimums (dans notre cas le nombre de réplicas) pour créer un POD ou des PODs avec un serveur HTTP Nginx
- un opérateur ayant un contrôleur se basant sur la CR créée afin de créer ou supprimer les éléments voulus (les PODs).
C’est bien mais ça demande un peu plus d’intelligence, c’est ce que nous allons voir dans le paragraphe suivant !
Plus d’intelligence !!! 🧠
Nous allons donc aller un peu plus loin :
- permettre d’utiliser le serveur Nginx déployé (c’est la moindre des choses 😅) en ajoutant un service (en mode node port),
- ajouter une supervision de notre deployment : si il est supprimé autrement que via la CR, on le recrée.
Un Ops dans le code 👷
Nous commençons par ce dernier point : recréer le deployment si il est supprimé par inadvertence. Pour cela on va utiliser une deuxième notion des opérateurs : la notion de watch. C’est assez simple : on va abonner notre opérateur à certains évènements et il réagira en fonction. Dans notre exemple cela va donner quelque chose du genre :
envoie moi tous les évènements en rapport aux déploiements d’un certain namespace.
Voyons comment faire cela en adaptant notre code précédent et, comme vous vous en doutez peut être, tout va se faire dans le contrôleur. Il suffit d’ajouter dans la méthode createOrUpdateResource la gestion des évènements sur le deployment créé :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Override
public UpdateControl<NginxInstallerResource> reconcile(NginxInstallerResource resource, Context context) {
System.out.println("🛠️ Create / update Nginx resource operator ! 🛠️");
String namespace = resource.getMetadata().getNamespace();
// Load the Nginx deployment
Deployment deployment = loadYaml(Deployment.class, "/k8s/nginx-deployment.yml");
// Apply the number of replicas
deployment.getSpec().setReplicas(resource.getSpec().getReplicas());
deployment.getMetadata().setNamespace(namespace);
// Create or update the modifications
k8sClient.apps().deployments().inNamespace(namespace).createOrReplace(deployment);
// Watch events on the Nginx deployment
watch = k8sClient.apps().deployments().withName(deployment.getMetadata().getName())
.watch(new Watcher<Deployment>() {
@Override
public void eventReceived(Action action, Deployment resource) {
System.out.println("⚡ Event receive on watcher ! ⚡ ➡️ " + action.name());
if (action == Action.DELETED) {
System.out.println("🗑️ Deployment deleted, recreate it ! 🗑️");
k8sClient.apps().deployments().inNamespace(resource.getMetadata().getNamespace())
.createOrReplace(deployment);
}
}
@Override
public void onClose(WatcherException cause) {
System.out.println("☠️ Watcher closed due to unexpected error : " + cause);
}
});
return UpdateControl.updateResource(resource);
}
Ce qui nous intéresse se trouve dans la méthode watch : on récupère l’évènement pour tester si il est de type DELETE afin de recréer le deployment que l’on vient de supprimer.
Cela donne en exécutant l’opérateur :
1
2
3
4
5
6
7
8
mvn exec:java -Dexec.mainClass=fr.wilda.NginxInstallerRunner
🚀 Starting NginxInstaller operator !!! 🚀
🛠️ Create / update Nginx resource operator ! 🛠️
⚡ Event receive on watcher ! ⚡ ➡️ ADDED
⚡ Event receive on watcher ! ⚡ ➡️ MODIFIED
⚡ Event receive on watcher ! ⚡ ➡️ MODIFIED
⚡ Event receive on watcher ! ⚡ ➡️ MODIFIED
1
2
3
4
kubectl get pods -n test-nginx-operator
NAME READY STATUS RESTARTS AGE
nginx-deployment-69c78cd8c6-5g5kq 1/1 Running 0 3s
nginx-deployment-69c78cd8c6-6t7dx 1/1 Running 0 3s
On supprime le deployment sans passer par la suppression de la CR : kubectl delete deployment/nginx-deployment -n test-nginx-operator
Vérifions ce que ça a provoqué côté opérateur:
1
2
3
4
5
6
7
8
9
⚡ Event receive on watcher ! ⚡ ➡️ DELETED
🗑️ Deployment deleted, recreate it ! 🗑️
⚡ Event receive on watcher ! ⚡ ➡️ ADDED
⚡ Event receive on watcher ! ⚡ ➡️ MODIFIED
⚡ Event receive on watcher ! ⚡ ➡️ MODIFIED
⚡ Event receive on watcher ! ⚡ ➡️ MODIFIED
⚡ Event receive on watcher ! ⚡ ➡️ MODIFIED
⚡ Event receive on watcher ! ⚡ ➡️ MODIFIED
⚡ Event receive on watcher ! ⚡ ➡️ MODIFIED
Et enfin dans notre cluster:
1
2
3
4
kubectl get pods -n test-nginx-operator
NAME READY STATUS RESTARTS AGE
nginx-deployment-64d8d4556f-nrhdw 1/1 Running 0 115s
nginx-deployment-64d8d4556f-zftlf 1/1 Running 0 115s
On a bien notre Ops qui veille au grain et recrée notre deployment en cas de disparition de celui-ci !
Si vous avez bien suivi il nous reste un dernière adaptation à faire. En effet, notre ops est un peu trop zélé car même si on supprime la ressource il va recréer le deployment ! Pour cela, il faut lui dire d’arrêter de surveiller notre deployment en cas de suppression de la CR:
1
2
3
4
5
6
7
8
9
10
11
@Override
public DeleteControl cleanup(NginxInstallerResource resource, Context context) {
System.out.println("💀 Delete Nginx resource operator ! 💀");
// Avoid the automatic recreation
if (watch != null) watch.close();
// Delete deployment and its PODs
k8sClient.apps().deployments().inNamespace(resource.getMetadata().getNamespace()).delete();
return DeleteControl.defaultDelete();
}
C’est close sur le watch qui nous permet de dire à notre ops de se rendormir tranquillement 😉.
Terminer la configuration 🛠️
Nous venons de fournir à notre opérateur un ops virtuel mais son premier travail, à savoir l’installation, n’est pas complet. Notre serveur Nginx n’est pas accessible de l’extérieur. Pour cela, je rajoute un service en mode node port pour se simplifie la vie (à ne pas reproduire chez vous !). Comme pour le deployment je passe par un fichier yaml mais il est possible de tout faire en version code.
Le service :
1
2
3
4
5
6
7
8
9
10
11
12
apiVersion: v1
kind: Service
metadata:
name: "nginx-service"
spec:
selector:
app: "nginx"
ports:
- protocol: TCP
port: 80
targetPort: 80
type: NodePort
Enfin l’adaptation de notre contrôleur pour créer ces deux ressources :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Override
public UpdateControl<NginxInstallerResource> reconcile(NginxInstallerResource resource, Context context) {
System.out.println("🛠️ Create / update Nginx resource operator ! 🛠️");
String namespace = resource.getMetadata().getNamespace();
String namespace = resource.getMetadata().getNamespace();
// Load the Nginx deployment
Deployment deployment = loadYaml(Deployment.class, "/k8s/nginx-deployment.yml");
// Apply the number of replicas
deployment.getSpec().setReplicas(resource.getSpec().getReplicas());
deployment.getMetadata().setNamespace(namespace);
// Create or update the modifications
k8sClient.apps().deployments().inNamespace(namespace).createOrReplace(deployment);
// Watch events on the Nginx deployment
watch = k8sClient.apps().deployments().withName(deployment.getMetadata().getName())
.watch(new Watcher<Deployment>() {
@Override
public void eventReceived(Action action, Deployment resource) {
System.out.println("⚡ Event receive on watcher ! ⚡ ➡️ " + action.name());
if (action == Action.DELETED) {
System.out.println("🗑️ Deployment deleted, recreate it ! 🗑️");
k8sClient.apps().deployments().inNamespace(resource.getMetadata().getNamespace())
.createOrReplace(deployment);
}
}
@Override
public void onClose(WatcherException cause) {
System.out.println("☠️ Watcher closed due to unexpected error : " + cause);
}
});
// Create service
Service service = loadYaml(Service.class, "/k8s/nginx-service.yml");
k8sClient.services().inNamespace(namespace).createOrReplace(service);
return UpdateControl.updateResource(resource);
}
Comme on peut le constater cela reprend exactement le même principe que pour le deployment mais ici on crée notre service.
Testons tout ça :
Déploiement dans Kubernetes 🐳
On a un opérateur qui a plus d’intelligence que notre simple Hello World du dernier article. Bien sûr ce n’est pas les fonctionnalités les plus impressionnantes du monde mais maintenant que le principe est là on peut faire un peut tout type d’action.
Il nous reste une dernière chose, le déployer de manière autonome sur notre cluster Kubernetes. Et là, j’ai eu une mauvaise surprise qui m’a occupée quelques jours, je vais vous la partager afin de vous faire gagner du temps !
Le deployment de l’opérateur 📝
Avant de passer à la création de l’image (et ses déboires) il faut créer le deployment pour notre opérateur, rien de bien extraordinaire :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
apiVersion: v1
kind: Namespace
metadata:
name: nginx-operator
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-operator
namespace: nginx-operator
spec:
selector:
matchLabels:
app: nginx-operator
replicas: 1
strategy:
type: Recreate
template:
metadata:
labels:
app: nginx-operator
spec:
containers:
- name: operator
image: localhost:5000/nginx-operator
imagePullPolicy: Always
A l’uber jar au revoir tu diras 🍱
Pour packager et déployer mon opérateur j’étais parti sur la même chose que pour mon Hello World : Dockerfile et uber jar (ou fat jar). Mais voilà rien n’est simple dans la vie et une fois mon image déployée dans un POD, mon opérateur refusait systématiquement de passer les commandes fabric8 pour créer le deployment avec l’erreur :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
🚀 Starting NginxInstaller operator !!! 🚀
🛠️ Create / update Nginx resource operator ! 🛠️
07:45:58.215 ERROR io.javaoperatorsdk.operator.processing.EventDispatcher.handleExecution(EventDispatcher.java:55) - Error during event processing ExecutionScope{events=[CustomResourceEvent{action=MODIFIED, resource=[ name=nginx-installer, kind=NginxInstallerResource, apiVersion=fr.wilda/v1 ,resourceVersion=274608, markedForDeletion: false ]}], customResource uid: 00ebe0d1-5f22-4982-89bf-fb029b9349d0, version: 274608} failed.
java.lang.IllegalStateException: No adapter available for type:class io.fabric8.kubernetes.client.AppsAPIGroupClient
at io.fabric8.kubernetes.client.BaseClient.adapt(BaseClient.java:134) ~[operator.jar:?]
at io.fabric8.kubernetes.client.BaseKubernetesClient.apps(BaseKubernetesClient.java:523) ~[operator.jar:?]
at fr.wilda.controller.NginxInstallerController.createOrUpdateResource(NginxInstallerController.java:46) ~[operator.jar:?]
at fr.wilda.controller.NginxInstallerController.createOrUpdateResource(NginxInstallerController.java:20) ~[operator.jar:?]
at io.javaoperatorsdk.operator.processing.ConfiguredController$2.execute(ConfiguredController.java:101) ~[operator.jar:?]
at io.javaoperatorsdk.operator.processing.ConfiguredController$2.execute(ConfiguredController.java:76) ~[operator.jar:?]
at io.javaoperatorsdk.operator.Metrics.timeControllerExecution(Metrics.java:23) ~[operator.jar:?]
at io.javaoperatorsdk.operator.processing.ConfiguredController.createOrUpdateResource(ConfiguredController.java:75) ~[operator.jar:?]
at io.javaoperatorsdk.operator.processing.EventDispatcher.handleCreateOrUpdate(EventDispatcher.java:127) ~[operator.jar:?]
at io.javaoperatorsdk.operator.processing.EventDispatcher.handleDispatch(EventDispatcher.java:87) ~[operator.jar:?]
at io.javaoperatorsdk.operator.processing.EventDispatcher.handleExecution(EventDispatcher.java:46) [operator.jar:?]
at io.javaoperatorsdk.operator.processing.DefaultEventHandler$ControllerExecution.run(DefaultEventHandler.java:360) [operator.jar:?]
at java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) [?:?]
at java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) [?:?]
at java.lang.Thread.run(Unknown Source) [?:?]
Après quelques heures / jours de recherches j’ai mis en doute mon image elle-même (il m’en a fallu du temps me direz vous ! 😅), j’ai donc décidé d’utiliser la même technique que dans les exemples de l’opérateur, à savoir le plugin maven jib pour fabriquer l’image.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<plugins>
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<from>
<image>adoptopenjdk:11-jre</image>
</from>
<to>
<image>localhost:5000/nginx-operator</image>
</to>
</configuration>
</plugin>
Un petit coup de maven : mvn clean compile jib:dockerBuild
Et là magie cela fonctionne du premier coup !
Si une personne a une explication j’avoue que je suis preneur car à ce stade je ne vois pas l’explication. Ca sent un problème de précédence dans le classpath mais je n’arrive pas à mettre le doigt dessus !
Conclusion 🧐
Avec cet exemple un peu plus poussé j’espère que vous avez pu entrevoir toutes les possibilités d’un opérateur et sa facilité de l’écrire en Java.
L’ensemble des sources est disponible dans le projet GitHub java-k8s-nginx-operator.
Merci de m’avoir lu et si vous avez vu des coquilles n’hésitez pas à me l’indiquer sur le repository des sources ou de l’article.
Comments