Was ist Kubebuilder und wobei kann er uns helfen?

Oder wie Marvin und Julian Kubernetes beibrachten einen Pokemon zu „fangen“.

 

Heute möchte ich darüber berichten wie Marvin (Cloud Engeneer bei teuto.net) und Julian (Software Developer narando & TrackCode GmbH) sich mit Kubernetes und dem Kubebuilder auf die Jagd nach Pokemons gemacht haben.

 

Marvin, was ist Kubebuilder und wofür kann man es nutzen?

Marvin: „Kubebuilder ist laut eigener Aussage ein „framework for building Kubernetes APIs using custom resource definitions (CRDs).

Um besser erklären zu können, was ein Kubebuilder ist und macht, möchte ich noch kurz auf Kubernetes eingehen.

Die besondere Stärke von Kubernetes liegt in der Verwaltung und Überwachung von Ressourcen, z.b. Container oder Storage Volumes. Zusätzlich bietet Kubernetes jedoch die Möglichkeit eigene Custom Ressourcen (CRD) zu definieren. 
Diese können z.b. Datenbanken oder Training Jobs für neuronale Netzwerke oder, wie in diesem Artikel, Pokemonscool sein. Im Kubernetes Cluster läuft dazu ein Programm (Controller), das sich um die Verwaltung dieser Custom Ressoucen kümmert. Mit der Kubernetes API kann jeder Entwickler diese speziellen Ressourcen beim Kubernetes Cluster anfordern.

Kubebuilder bietet eine wesentliche Erleichterung bei der Erstellung von Controllern für Custom Ressources. Das erklärte Ziel ist dabei, dass man sich auf den Kern seiner Ressource konzentrieren kann und nur die eigene Logik erstellen muss. Das Erzeugen des Projekts und alles weitere erledigt Kubebuilder.“

 

Unser Beispiel aus der Praxis – wie fing alles an?

Marvin: „Nachdem ich den Workshop „Introduction to CRDs with Kubebuilder“ von Google auf der Kubecon Barcelona besucht hatte, wollte ich mich noch ein wenig selbst mit dem Kubebuilder beschäfigen. Da bot es sich an das ein Meetup vor der Tür stand. Also habe ich mich zusammen mit Julian, der zufällig auch den Workshop besucht hat, daran gemacht eine eigene Custom Ressource zu entwickeln. Nach einigen Überlegungen, stießen wir auf die PokeAPI.

Die PokeAPI stellt Informationen zu einer beinahe endlosen Liste von Pokemon bereit. (Ok, nach ein paar API-Calls weiß ich nun das es 807 eingetragene Pokemon sind.) Unsere Überlegung war nun, daß wir eine Custom Ressource erstellen, welcher wir den Namen eines Pokemons geben können und diese gibt uns eine ConfigMap mit allen Informationen zu diesem Pokemon zurück.“

 

Unsere 7 Schritte, um unseren Use Case umzusetzen

1) Download der Ressourcen

Für das Erstellen des Projekts benötigen wir Kubebuilder und Kustomize. Für genauere Informationen zur Installation kann das offizielle Kubebuilder Buch herangezogen werden.

2) Erstellen des Basisprojekts

Nachdem die benötigten Programme installiert wurden, können wir das Projekt initialisieren. Das geschieht mit den folgenden Befehlen:

kubebuilder create api --group pokemon --version v1beta1 --kind Pokemon

Nach diesen Befehlen erstellt kubebuilder einige Verzeichnisse und Dateien. Die wichtigsten sehen wir uns an, während wir die Dateien bearbeiten.

3) Erstellen der API

Zuerst müssen wir in der Datei api/v1beta1/pokemon_types.go das Struct PokemonSpec bearbeiten. Wir fügen zwei neue Spec Felder hinzu:

  • PokemonName – Der Name des Pokemons von dem wir die Infos haben möchten
  • ConfigMapName – Der Name der ConfigMap in der die Informationen gespeichert werden sollen
    Nachdem ich die beiden Felder eingefügt habe sieht der Block so aus:

 

type PokemonSpec struct {
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
// Important: Run "make" to regenerate code after modifying this file
// Name of the Pokemon we want to catch
PokemonName string `json:"pokemonName,omitempty"`
// Name of the onfigmap to store the pokemon in
ConfigMapName string `json:"configMapName,omitempty"`
}

Die Spec Felder werden genutzt um den Status des Objekts zu überwachen. Der gewünschte Zustand (spec) wird mit dem aktuellen Cluster Status abgeglichen und der resultierende Zustand gespeichert (status).

Wenn wir also die pokemon_types.go bearbeitet haben, müssen wir noch die Custom Ressource Definition generieren.

4) Custom Ressource Definition

Die CRD muss noch generiert werden. Im Hauptverzeichnis liegt ein Makefile mit dem target ‚manifests‘. Also wird nur make manifests aufgerufen. Wenn der Befehl durchgelaufen ist können wir nun die Datei config/crd/bases/pokemon.poketeuto.net_pokemons.yaml öffnen. Dort sehen wir das die vorher definiereten spec-Felder in die CRD generiert wurden. So kann Kubernetes die Angaben später nutzen.

 

...
spec:
properties:
configMapName:
description: Name of the Configmap to store the pokemon in
type: string
pokemonName:
description: Name of the Pokemon we want to catch
type: string
...

5) Implementieren der PokeApi Funktionen

Zunächst werden wir dann erstmal eine Funktion erstellen mit der wir die Informationen zu den Pokemon abrufen können. Dazu haben wir eine Datei mit Namen pokeapi.go im Ordner pokeapi erstellt.

Neben einigen Imports und Variablen haben wir folgende Funktion geschrieben:

func GetPokemon(ctx context.Context, name string) (Pokemon, error) {
res, err := http.Get(pokeAPIHost + name)
if err != nil {
return Pokemon{}, err
}
pokemon := Pokemon{}
err = json.NewDecoder(res.Body).Decode(&pokemon)
if err != nil {
return Pokemon{}, err
}
return pokemon, nil
}

Der komplette Code kann in Julians Repository eingesehen werden.

Diese Funktion können wir nun Nutzen wenn ein neues Pokemon angelegt werden soll.

Implementieren des Controllers
Nun werden wir die wichtigste Funktion implementieren, den Controller. Ein Controller sorgt dafür, dass der aktuelle Status im Cluster dem Status entspricht, der vom Objekt vorgegeben ist.

Dieser Vorgang wird ‚reconciling‘ genannt. Die Logik, die diesen Prozess implementiert, nennt man dementsprechend, reconciler. Und genau diese Logik werden wir nun implementieren. Dazu sehen wir uns die Datei controllers/pokemon_controller.go an

Auch in dieser Datei sind einige Teile schon generiert worden. Neben einigen Imports ist dort schon das PokemonReconciler Struct angelegt worden. Hiermit starten wir.

type PokemonReconciler struct {
client.Client
Log logr.Logger
}

In diesem Struct müssen wir nun das Scheme hinzufügen. Das Scheme sorgt dafür das ein Go Type auf ein Group Version Kind gemappt werden kann. Nähere Informationen dazu findet man wieder im Kubebuilder Buch.

Anschließend sieht das Struct so aus:

type PokemonReconciler struct {
client.Client
Log logr.Logger
Scheme *runtime.Scheme
}

Nachdem der Pokemon-Typ nun gemappt werden kann, können wir uns die Renconcile Funktion ansehen. Diese wurde auch schonmal angelegt und sieht so aus:

func (r *PokemonReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
_ = context.Background()
_ = r.Log.WithValues("pokemon", req.NamespacedName)
// your logic here
return ctrl.Result{}, nil
}

Der Kommentar zeigt schon wo die Reise hingeht. Also fangen wir mal an.

...
var pokemonSync kubebuilderv1.Pokemon
if err := r.Get(ctx, req.NamespacedName, &pokemonSync); err != nil {
log.Error(err, "unable to fetch pokemon")
return ctrl.Result{}, ignoreNotFound(err)
...
}

In der ersten Zeile legen wir ein Pokemon Objekt an und versuchen es dann mit einem API Objekt zu befüllen. Für den Fall, das dieser Aufruf nicht funktioniert, loggen wir einen Fehler raus und benutzen dann eine Hilfsfunktion um zu prüfen, ob es ein ‚NotFound‘ error ist. Diesen Fehler würden wir dann ignorieren, da wir diese nicht fixen können, indem wir das Objekt nochmal neu erstellen.

Nun kommen wir zum Hauptteil der reconcile Funktion:

pokemonData, err := pokeapi.GetPokemon(ctx, pokemonSync.Spec.PokemonName)
if err != nil {
log.Error(err, "unable to fetch pokemon data from pokeapi")
return ctrl.Result{}, err
}
configMap := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: pokemonSync.Spec.ConfigMapName, Namespace: pokemonSync.Namespace}}
if _, err := ctrl.CreateOrUpdate(ctx, r.Client, configMap, func() error {
if configMap.Data == nil {
// Initialize Data map for new objects
configMap.Data = make(map[string]string)
}
configMap.Data["ID"] = strconv.Itoa(pokemonData.ID)
configMap.Data["Name"] = pokemonData.Name
configMap.Data["Height"] = strconv.Itoa(pokemonData.Height)
configMap.Data["Weight"] = strconv.Itoa(pokemonData.Weight)
configMap.Data["BaseExperience"] = strconv.Itoa(pokemonData.BaseExperience)
// Set ownership for automatic clean up
if err := ctrl.SetControllerReference(&pokemonSync, configMap, r.Scheme); err != nil {
return err
}
return nil
}); err != nil {
log.Error(err, "unable to update config map")
}
return ctrl.Result{}, nil

Zunächst nutzen wir die zuvor erstellte Funktion, um die Daten des Pokemons zu bekommen, welches wir in dem Pokemon Objekt eingetragen haben. Danach legen wir eine Configmap an, in der die Daten des Pokemons gespeichert werden können.

Die Zeile ‚SetControllerReference‘ setzt die Beziehung zwischen der Configmap und dem Pokemon Objekt. Wenn das Pokemon Objekt gelöscht wird, wird auch automatisch die Configmap gelöscht.

Die letzten Zeilen beschreiben die Funktion ‚SetupWithManager‘. Diese Funktion wird benutzt, um den Reconciler zum Manager hinzuzufügen.

func (r *PokemonReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&kubebuilderv1.Pokemon{}).
Owns(&corev1.ConfigMap{}).
Complete(r)
}

Damit ist der Controller soweit fertig. Jetzt muss nur noch die Main Funktion erstellt werden.

6) Die Main-Funktion

Die Main wurde auch schon vorgeneriert. Hier müssen wir zunächst die ‚corev1‘ zum Scheme hinzufügen. Das passiert in der ‚init‘-Funktion.

...
func init() {
kubebuilderv1.AddToScheme(scheme)
corev1.AddToScheme(scheme)
// +kubebuilder:scaffold:scheme
}
...

Als letzten Schritt fügen wir noch das Scheme bei der Erstellung des PokemonReconcilers hinzu.

...
err = (&controllers.PokemonReconciler{
Client: mgr.GetClient(),
Log:    ctrl.Log.WithName("controllers").WithName("Pokemon"),
Scheme: scheme,
}).SetupWithManager(mgr)
...

Und damit sollte soweit alles erstellt sein. Das werden wir nun prüfen, indem wir die Custom Ressource auf einem Cluster installieren.

7) Installation

Für die nächsten Schritte braucht man Zugriff auf einen Kubernetes Cluster. Es sollte auch mit ‚Minikube‘ funktionieren, jedoch habe ich es mit einem Kubernetes in unserer teuto.net Cloud ausgeführt.

Zunächst installiert man die Custom Ressource im Cluster:

Wenn dieser Schritt funktioniert hat, sollte man mit einem kubectl get crds sehen das eine ‚pokemons.pokemon.poketeuto.net‘
Ressource vorhanden ist.

Um zu testen ob der Controller funktioniert, kann man nun noch einmal den Befehl make run ausführen. Es sollten einige Log Zeilen ausgegeben werden. Wenn soweit alles gut aussieht, kann man nun ein Pokemon Objekt anlegen. Praktischerweise hat kubebuiler auch schon ein Beispiel Objekt erstellt, was noch einwenig angepasst werden muss. Zu finden ist die yaml Datei unter dem Pfad: ‚config/samples/pokemon_v1beta1_pokemon.yaml‘.

Zum Testen des Controllers habe ich mir überlegt,  daß ich die Info’s zu Pikachu haben möchte. Die Datei sollte, wie folgt, aussehen:

apiVersion: pokemon.poketeuto.net/v1beta1 kind: Pokemon metadata: name: pikachu spec: # Add fields here pokemonName: pikachu configMapName: pikachu

Wenn die Datei mit kubectl apply -f .yaml angelegt wurde sollte man mit kubectl get pokemon sehen können das dort ein Pokemon Objekt mit Namen ‚pikachu‘ liegt.

Marvin, was ist dein Fazit?

Marvin: „Kubebuilder nimmt einem viel Schreibarbeit ab und erstellt viele Dateien, die sonst von Hand angelegt werden müssten. Außerdem kann man sich mehr auf seinen eigentlichen Use Case konzentrieren.

Wie oben bereits erwähnt, alle Dateien der Pokemon Costum Ressource könnt Ihr Euch über Julians Repository nochmal ansehen und für tiefergehende Informationen empfehle ich Euch das Kubebuilder Buch.“

 

Ich hoffe, wir konnten euch einen Einblick in den Kubebuilder geben und freuen uns über euer Feedback

Ihr möchtet noch mehr über Kubernetes und seine Begrifflichkeiten wissen, dann stöbert gern in unserer Wissensbasis.

Bis demnächst bei  „Kubernetes und ich“