Ce tutoriel est spécifiquement destiné aux développeurs ayant une expérience Java qui souhaitent apprendre à écrire rapidement le premier opérateur Kubernetes. Pourquoi des opérateurs ? Il y a plusieurs avantages :
Je vais essayer de limiter la théorie au minimum et de montrer une recette infaillible comment « faire un gâteau ». J'ai choisi Java car il est proche de mon expérience professionnelle et pour être honnête, c'est plus facile que Go (mais certains peuvent ne pas être d'accord).
Allons-y directement.
Personne n’aime lire une longue documentation, mais sortons-le rapidement de notre poitrine, d’accord ?
Qu'est-ce qu'un pod ?
Pod est un groupe de conteneurs avec une interface réseau partagée (et une adresse IP unique) et un stockage.
Qu'est-ce qu'un ensemble de répliques ?
Le jeu de réplicas contrôle la création et la suppression de pods afin qu'à chaque instant, il y ait exactement un nombre spécifié de pods avec un modèle donné.
Qu'est-ce que le déploiement ?
Le déploiement possède un jeu de réplicas et possède indirectement des pods. Lorsque vous créez des pods de déploiement, des pods sont créés lorsque vous les supprimez.
Qu'est-ce que le service ?
Le service est un point de terminaison Internet UNIQUE pour un groupe de pods (il répartit la charge entre eux de manière égale). Vous pouvez l'exposer pour qu'il soit visible de l'extérieur du cluster. Il automatise la création de tranches de points de terminaison.
Le problème avec Kubernetes est que dès le départ, il a été conçu pour être apatride. Les ensembles de réplicas ne suivent pas l'identité des pods ; lorsqu'un pod particulier disparaît, un nouveau est simplement créé. Certains cas d'utilisation nécessitent un état, comme les bases de données et les clusters de cache. Les ensembles avec état n'atténuent que partiellement le problème.
C'est pourquoi les gens ont commencé à écrire des opérateurs pour alléger le fardeau de la maintenance. Je n'entrerai pas dans les profondeurs du modèle et des différents SDK — vous pouvez commencer à partir d'ici.
Tout ce qui fonctionne dans Kubernetes, chaque petit équipement de machinerie est basé sur un concept simple de boucle de contrôle. Ainsi, ce que fait cette boucle de contrôle pour un type de ressource particulier, c'est qu'elle vérifie ce qui est et ce qui devrait être (tel que défini dans le manifeste). S'il y a une incompatibilité, il essaie d'effectuer certaines actions pour résoudre ce problème. C'est ce qu'on appelle la réconciliation.
Et ce que sont réellement les opérateurs, c'est le même concept mais pour les ressources personnalisées. Les ressources personnalisées sont le moyen d'étendre l'API Kubernetes à certains types de ressources que vous définissez. Si vous configurez crd dans Kubernetes, toutes les actions telles que obtenir, lister, mettre à jour, supprimer, etc. seront possibles sur cette ressource. Et qu’est-ce qui fera le travail réel ? C'est vrai, notre opérateur.
Comme d'habitude pour tester une technologie pour la première fois, vous choisissez le problème le plus basique à résoudre. Parce que le concept est particulièrement complexe alors hello world dans ce cas sera un peu long. Quoi qu'il en soit, dans la plupart des sources, j'ai vu que le cas d'utilisation le plus simple consiste à configurer la diffusion de pages statiques.
Le projet est donc comme ceci : nous allons définir une ressource personnalisée qui représente deux pages que nous voulons servir. Après avoir appliqué cet opérateur de ressources, il configurera automatiquement l'application de service dans Spring Boot, créera une carte de configuration avec le contenu des pages, montera la carte de configuration dans un volume dans le pod d'applications et configurera le service pour ce pod. Ce qui est amusant, c'est que si nous modifions la ressource, elle reliera tout à la volée et les nouvelles modifications de page seront instantanément visibles. La deuxième chose amusante est que si nous supprimons la ressource, cela supprimera tout, laissant notre cluster propre.
Servir l'application Java
Ce sera un serveur de pages statiques très simple dans Spring Boot. Vous n'aurez besoin que de spring-boot-starter-web, alors allez-y et choisissez :
L'application est juste ceci :
@SpringBootApplication @RestController public class WebpageServingApplication { @GetMapping(value = "/{page}", produces = "text/html") public String page(@PathVariable String page) throws IOException { return Files.readString(Path.of("/static/"+page)); } public static void main(String[] args) { SpringApplication.run(WebpageServingApplication.class, args); } }
Tout ce que nous transmettons comme variable de chemin sera récupéré du répertoire /static (dans notre cas, page1 et page2). Le répertoire statique sera donc monté à partir de la carte de configuration, mais nous en reparlerons plus tard.
Alors maintenant, nous devons créer une image native et la transférer vers le référentiel distant.
Conseil numéro 1
<plugin> <groupId>org.graalvm.buildtools</groupId> <artifactId>native-maven-plugin</artifactId> <configuration> <buildArgs> <buildArg>-Ob</buildArg> </buildArgs> </configuration> </plugin>
En configurant GraalVM de cette manière, vous obtiendrez la construction la plus rapide avec la consommation de mémoire la plus faible (environ 2 Go). Pour moi, c'était un must car je n'ai que 16 Go de mémoire et beaucoup de choses installées.
Astuce numéro 2
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <image> <publish>true</publish> <builder>paketobuildpacks/builder-jammy-full:latest</builder> <name>ghcr.io/dgawlik/webpage-serving:1.0.5</name> <env> <BP_JVM_VERSION>21</BP_JVM_VERSION> </env> </image> <docker> <publishRegistry> <url>https://ghcr.io/dgawlik</url> <username>dgawlik</username> <password>${env.GITHUB_TOKEN}</password> </publishRegistry> </docker> </configuration> </plugin>
So now you do:
mvn spring-boot:build-image
And that’s it.
Operator with Fabric8
Now the fun starts. First you will need this in your pom:
<dependencies> <dependency> <groupId>io.fabric8</groupId> <artifactId>kubernetes-client</artifactId> <version>6.13.4</version> </dependency> <dependency> <groupId>io.fabric8</groupId> <artifactId>crd-generator-apt</artifactId> <version>6.13.4</version> <scope>provided</scope> </dependency> </dependencies>
crd-generator-apt is a plugin that scans a project, detects CRD pojos and generates the manifest.
Since I mentioned it, these resources are:
@Group("com.github.webserving") @Version("v1alpha1") @ShortNames("websrv") public class WebServingResource extends CustomResource<WebServingSpec, WebServingStatus> implements Namespaced { }
public record WebServingSpec(String page1, String page2) { }
public record WebServingStatus (String status) { }
What is common in all resource manifests in Kubernetes is that most of them has spec and status. So you can see that the spec will consist of two pages pasted in heredoc format. Now, the proper way to handle things would be to manipulate status to reflect whatever operator is doing. If for example it is waiting on deployment to finish it would have status = “Processing”, on everything done it would patch the status to “Ready” and so on. But we will skip that because this is just simple demo.
Good news is that the logic of the operator is all in main class and really short. So step by step here it is:
KubernetesClient client = new KubernetesClientBuilder() .withTaskExecutor(executor).build(); var crdClient = client.resources(WebServingResource.class) .inNamespace("default"); var handler = new GenericResourceEventHandler<>(update -> { synchronized (changes) { changes.notifyAll(); } }); crdClient.inform(handler).start(); client.apps().deployments().inNamespace("default") .withName("web-serving-app-deployment").inform(handler).start(); client.services().inNamespace("default") .withName("web-serving-app-svc").inform(handler).start(); client.configMaps().inNamespace("default") .withName("web-serving-app-config").inform(handler).start();
So the heart of the program is of course Fabric8 Kuberenetes client built in first line. It is convenient to customize it with own executor. I used famous virtual threads, so when waiting on blocking io java will suspend the logic and move to main.
How here is a new part. The most basic version would be to run forever the loop and put Thread.sleep(1000) in it or so. But there is more clever way - kubernetes informers. Informer is websocket connection to kubernetes api server and it informs the client each time the subscribed resource changes. There is more to it you can read on the internet for example how to use various caches which fetch updates all at once in batch. But here it just subscribes directly per resource. The handler is a little bit bloated so I wrote a helper class GenericResourceEventHandler.
public class GenericResourceEventHandler<T> implements ResourceEventHandler<T> { private final Consumer<T> handler; public GenericResourceEventHandler(Consumer<T> handler) { this.handler = handler; } @Override public void onAdd(T obj) { this.handler.accept(obj); } @Override public void onUpdate(T oldObj, T newObj) { this.handler.accept(newObj); } @Override public void onDelete(T obj, boolean deletedFinalStateUnknown) { this.handler.accept(null); } }
Since we only need to wake up the loop in all of the cases then we pass it a generic lambda. The idea for the loop is to wait on lock in the end and then the informer callback releases the lock each time the changes are detected.
Next:
for (; ; ) { var crdList = crdClient.list().getItems(); var crd = Optional.ofNullable(crdList.isEmpty() ? null : crdList.get(0)); var skipUpdate = false; var reload = false; if (!crd.isPresent()) { System.out.println("No WebServingResource found, reconciling disabled"); currentCrd = null; skipUpdate = true; } else if (!crd.get().getSpec().equals( Optional.ofNullable(currentCrd) .map(WebServingResource::getSpec).orElse(null))) { currentCrd = crd.orElse(null); System.out.println("Crd changed, Reconciling ConfigMap"); reload = true; }
If there is no crd then there is nothing to be done. And if the crd changed then we have to reload everything.
var currentConfigMap = client.configMaps().inNamespace("default") .withName("web-serving-app-config").get(); if(!skipUpdate && (reload || desiredConfigMap(currentCrd).equals(currentConfigMap))) { System.out.println("New configmap, reconciling WebServingResource"); client.configMaps().inNamespace("default").withName("web-serving-app-config") .createOrReplace(desiredConfigMap(currentCrd)); reload = true; }
This is for the case that ConfigMap is changed in between the iterations. Since it is mounted in pod then we have to reload the deployment.
var currentServingDeploymentNullable = client.apps().deployments().inNamespace("default") .withName("web-serving-app-deployment").get(); var currentServingDeployment = Optional.ofNullable(currentServingDeploymentNullable); if(!skipUpdate && (reload || !desiredWebServingDeployment(currentCrd).getSpec().equals( currentServingDeployment.map(Deployment::getSpec).orElse(null)))) { System.out.println("Reconciling Deployment"); client.apps().deployments().inNamespace("default").withName("web-serving-app-deployment") .createOrReplace(desiredWebServingDeployment(currentCrd)); } var currentServingServiceNullable = client.services().inNamespace("default") .withName("web-serving-app-svc").get(); var currentServingService = Optional.ofNullable(currentServingServiceNullable); if(!skipUpdate && (reload || !desiredWebServingService(currentCrd).getSpec().equals( currentServingService.map(Service::getSpec).orElse(null)))) { System.out.println("Reconciling Service"); client.services().inNamespace("default").withName("web-serving-app-svc") .createOrReplace(desiredWebServingService(currentCrd)); }
If any of the service or deployment differs from the defaults we will replace them with the defaults.
synchronized (changes) { changes.wait(); }
Then the aforementioned lock.
So now the only thing is to define the desired configmap, service and deployment.
private static Deployment desiredWebServingDeployment(WebServingResource crd) { return new DeploymentBuilder() .withNewMetadata() .withName("web-serving-app-deployment") .withNamespace("default") .addToLabels("app", "web-serving-app") .withOwnerReferences(createOwnerReference(crd)) .endMetadata() .withNewSpec() .withReplicas(1) .withNewSelector() .addToMatchLabels("app", "web-serving-app") .endSelector() .withNewTemplate() .withNewMetadata() .addToLabels("app", "web-serving-app") .endMetadata() .withNewSpec() .addNewContainer() .withName("web-serving-app-container") .withImage("ghcr.io/dgawlik/webpage-serving:1.0.5") .withVolumeMounts(new VolumeMountBuilder() .withName("web-serving-app-config") .withMountPath("/static") .build()) .addNewPort() .withContainerPort(8080) .endPort() .endContainer() .withVolumes(new VolumeBuilder() .withName("web-serving-app-config") .withConfigMap(new ConfigMapVolumeSourceBuilder() .withName("web-serving-app-config") .build()) .build()) .withImagePullSecrets(new LocalObjectReferenceBuilder() .withName("regcred").build()) .endSpec() .endTemplate() .endSpec() .build(); } private static Service desiredWebServingService(WebServingResource crd) { return new ServiceBuilder() .editMetadata() .withName("web-serving-app-svc") .withOwnerReferences(createOwnerReference(crd)) .withNamespace(crd.getMetadata().getNamespace()) .endMetadata() .editSpec() .addNewPort() .withPort(8080) .withTargetPort(new IntOrString(8080)) .endPort() .addToSelector("app", "web-serving-app") .endSpec() .build(); } private static ConfigMap desiredConfigMap(WebServingResource crd) { return new ConfigMapBuilder() .withMetadata( new ObjectMetaBuilder() .withName("web-serving-app-config") .withNamespace(crd.getMetadata().getNamespace()) .withOwnerReferences(createOwnerReference(crd)) .build()) .withData(Map.of("page1", crd.getSpec().page1(), "page2", crd.getSpec().page2())) .build(); } private static OwnerReference createOwnerReference(WebServingResource crd) { return new OwnerReferenceBuilder() .withApiVersion(crd.getApiVersion()) .withKind(crd.getKind()) .withName(crd.getMetadata().getName()) .withUid(crd.getMetadata().getUid()) .withController(true) .build(); }
The magic of the OwnerReference is that you mark the resource which is it’s parent. Whenever you delete the parent k8s will delete automatically all the dependant resources.
But you can’t run it yet. You need a docker credentials in kubernetes:
kubectl delete secret regcred kubectl create secret docker-registry regcred \ --docker-server=ghcr.io \ --docker-username=dgawlik \ --docker-password=$GITHUB_TOKEN
Run this script once. Then we also need to set up the ingress:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: demo-ingress spec: rules: - http: paths: - path: / pathType: Prefix backend: service: name: web-serving-app-svc port: number: 8080
The workflow
So first you build the operator project. Then you take target/classes/META-INF/fabric8/webservingresources.com.github.webserving-v1.yml and apply it. From now on the kubernetes is ready to accept your crd. Here it is:
apiVersion: com.github.webserving/v1alpha1 kind: WebServingResource metadata: name: example-ws namespace: default spec: page1: | <h1>Hola amigos!</h1> <p>Buenos dias!</p> page2: | <h1>Hello my friend</h1> <p>Good evening</p>
You apply the crd kubectl apply -f src/main/resources/crd-instance.yaml. And then you run Main of the operator.
Then monitor the pod if it is up. Next just take the ip of the cluster:
minikube ip
And in your browser navigate to /page1 and /page2.
Then try to change the crd and apply it again. After a second you should see the changes.
The end.
A bright observer will notice that the code has some concurrency issues. A lot can happen in between the start and the end of the loop. But there are a lot of cases to consider and tried to keep it simple. You can do it as aftermath.
Like wise for the deployment. Instead of running it in IDE you can build the image the same way as for serving app and write deployment of it. That’s basically demystification of the operator — it is just a pod like every other.
I hope you found it useful.
Thanks for reading.
I almost forgot - here is the repo:
https://github.com/dgawlik/operator-hello-world
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!