Spelen met de Kubernetes API

Er is veel veranderd door de komst van containers en de container platformen. We kunnen het platform in zijn geheel als een nieuwe computer beschouwen, met Kubernetes als operating system. Net als ieder operating system, is er hier ook een API. Dus waarom gebruiken wij dit niet in onze applicaties?

Auteur: Vincent van Dam   

Met behulp van een simpele praktische use-case zien we hoe deze nieuwe Kubernetes uitbreiding in Java geïmplementeerd wordt. In dit artikel worden diverse Kubernetes concepten genoemd. Deze worden hier niet nader uitgelegd. Meer informatie over deze concepten is terug te vinden in de officiële Kubernetes documentatie [1].

De Kubernetes API

De basis van Kubernetes is eenvoudig. Er is een centrale key-value store (etcd) waarin definities worden opgeslagen van de componenten die op het platform aanwezig moeten zijn. Vervolgens zijn er een aantal services die deze store actief monitoren. Deze services zorgen ervoor dat de componenten, zoals gedefinieerd, in de daadwerkelijke staat beschikbaar zijn op het platform. Met andere woorden, als er in etcd staat dat er 10 replica’s van een container moeten draaien, is er een service in het platform die continu probeert dit component in deze staat te krijgen.

Deze API is in principe een gewone REST API en kan door iedereen worden gebruikt. Elke resource kan met een aantal standaard calls worden opgevraagd of aangepast. Ook kan er een Watch worden gedaan op een type resource. In dat geval blijft de connectie open en stuurt de Kubernetes API alle ‘add’, ‘update’ en ‘delete’ events die op dit type resource plaatsvinden direct door. Dit laatste is de basis die wordt gebruikt door de control services van Kubernetes om zo het cluster in de gewenste staat te houden.

De API is verder ook uit te breiden met eigen resources. Het enige wat er moet worden gedaan is een definitie aanmaken (custom resource definition, of in het kort CRD). Deze resource wordt daarna hetzelfde behandeld als iedere andere resource. Alle standaard API calls werken ook op de nieuw aangemaakte resource. Een praktisch gevolg is dat kubectl deze resource ook direct kent en het ook mogelijk wordt om bijvoorbeeld een ‘kubectl get’ te doen op deze resources.

Maar hoe kan deze API onze applicaties verrijken? Een populaire toepassing is het maken van operators. Een operator is een type service die verantwoordelijk is voor een aantal operationele taken. Bijvoorbeeld het installeren van een complexe applicatie op het cluster (denk bijvoorbeeld aan een database cluster).

Echter is een operator niet de enige toepassing die mogelijk is. Denk bijvoorbeeld aan een notificatie in een applicatie. Een melding als er groot onderhoud wordt gepleegd, of als er een storing gaande is. We kunnen de Kubernetes API uitbreiden met een custom resource die een melding beschrijft. Wanneer de storing gaande is, maken we de CRD aan en als de storing voorbij is, halen we de notificatie weer weg.

De applicatie kijkt net als Kubernetes of er een notificatie resource aanwezig is. Indien aanwezig laat hij een notificatie banner zien. Als de resource wordt veranderd of verwijderd, past de applicatie deze ook live aan bij de gebruiker.

Een praktisch voorbeeld

Laten we de banner use-case uitwerken en een service bouwen die op basis van een eigen gemaakte ‘Broadcast’ CRD en een websocket een bericht toont op een webpagina. Voordat we hier mee beginnen, moeten we eerst onze omgeving opzetten. Als je geen toegang hebt tot een Kubernetes cluster is Minikube een goed alternatief [2]. Minikube installeert een mini Kubernetes cluster lokaal op je pc die je kan gebruiken om je applicatie te testen.

Met een draaiende Kubernetes kunnen we onze eigen resource gaan toevoegen. Er zijn gelijk een aantal dingen die we moeten configureren. Onder andere de namen waarmee deze resource te benaderen is. Er kunnen verschillende versies van een resource bestaan, waarvoor we voor iedere versie een schema moeten configureren (Listing 1).

 apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: broadcasts.crddemo.joyrex2001.com
spec:
  group: crddemo.joyrex2001.com
  scope: Namespaced
  names:
    kind: Broadcast
    plural: broadcasts
    singular: broadcast
  versions:
  - name: v1
    storage: true
    served: true
    schema:
      openAPIV3Schema:
        type: object
        x-kubernetes-preserve-unknown-fields: true
Listing 1.

Met de CRD in het systeem, kunnen we nu een broadcast met kleur en tekst toevoegen (Listing 2). In onze CRD hebben we validatie uitgezet en hebben de gezegd dat Kubernetes instanties van onze resource zonder modificaties moet opslaan. Dit is handig voor development, omdat we dan niet genoodzaakt zijn om al onze velden vooraf te bedenken.

apiVersion: crddemo.joyrex2001.com/v1
kind: Broadcast
metadata:
  name: "hello"
spec:
  message: "Hello world!"
  color: "blue"
Listing 2.

Nadat we deze resource hebben toegevoegd met `kubectl apply -f hello.yaml`, is deze ook beschikbaar via de gebruikelijke kubectl commandos. We kunnen bijvoorbeeld alle Broadcast resources opvragen met `kubectl get broadcasts`. Als we live veranderingen willen zien, kunnen we `kubectl get broadcasts -w` doen.

Als we willen dat Kubernetes vooraf controleert of de resource aan bepaalde voorwaarden voldoet, kunnen we onze CRD strikter maken. We laten Kubernetes alleen resources accepteren die velden bevatten die in ons schema zijn gedefinieerd. Eventueel kan er volgens de OpenAPI standaard extra validatie worden toegevoegd. Ook kunnen we alternatieve kortere namen configureren voor onze resource (Listing 3).

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: broadcasts.crddemo.joyrex2001.com
spec:
  group: crddemo.joyrex2001.com
  scope: Namespaced
  names:
    plural: broadcasts
    singular: broadcast
    kind: Broadcast
    shortNames:
    - brc
  versions:
  - name: v1
    storage: true
    served: true
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              message:
                type: string
              color:
                type: string
Listing 3.

Nu we begrijpen hoe we resources kunnen definiëren en kunnen gebruiken, wordt het hoog tijd om ze te gaan gebruiken. Ik kies in mijn voorbeeld voor Quarkus [3] in combinatie met de fabric8 Kubernetes client [4]. Het voordeel van Quarkus is de snelheid waarmee applicaties worden opgestart en de lagere memory footprint. Deze twee aspecten maken dit framework erg geschikt voor container toepassingen.

We hebben natuurlijk ook wat classes nodig die de CRD implementeren. In principe hebben we er twee nodig; één om een enkele instantie te beschrijven en één om een lijst van meerdere instanties te omschrijven. Elke Kubernetes resource heeft ook een ‘metadata’ sectie, waarin onder andere de unieke naam of eventuele annotaties en labels worden toegevoegd. Dit moeten we dus ook in onze classes toevoegen (Listing 4).

public class Broadcast extends CustomResource {
    private static final long serialVersionUID = 1L;
    private BroadcastSpec spec;
    public BroadcastSpec getSpec() { return this.spec; }
    public void setSpec(BroadcastSpec spec) { this.spec = spec; }
}

public class BroadcastSpec {
    private String message;
    public String getMessage() { return this.message; }
    public void setMessage(String message) { this.message = message; }
    private String color;
    public String getColor() { return this.color; }
    public void setColor(String color) { this.color = color; }
}

public class BroadcastList extends CustomResourceList<Broadcast> {
    private static final long serialVersionUID = 1L;
}
Listing 4.

Bij de fabric8 Kubernetes client kunnen we kiezen uit een Watcher, of een Informer implementatie. Het verschil is dat de Watch implementatie de verantwoordelijkheid voor foutafhandeling en het in stand houden van de http connectie, bij de gebruiker van de API ligt. De Informer implementatie neemt deze verantwoordelijkheid over en is ook efficiënte door het cachen van resources.

Om onze informer te gebruiken, moeten we er voor zorgen dat deze wordt gestart wanneer de service begint. Vervolgens initialiseren we een informer voor onze CRD (Listing 5). In dit geval zeggen we dat er één keer per minuut een reconciliation moet worden gedaan. Dit betekent dat er elke minuut ook een volledige status van de resources wordt gestuurd, om te voorkomen dat er events verloren gaan, omdat er toevallig een connectie probleem was tijdens het realtime watchen.

public class Main {
  @Inject
  KubernetesClient client;

  void onStart(@Observes StartupEvent ev) {

    final CustomResourceDefinitionContext crd =
       new CustomResourceDefinitionContext.Builder()
            .withVersion("v1")
            .withScope("Namespaced")
            .withGroup("crddemo.joyrex2001.com")
            .withPlural("broadcasts")
            .build();

    final SharedInformerFactory factory = client.informers();
    final SharedIndexInformer<Broadcast> informer =
       sharedInformerFactory.sharedIndexInformerForCustomResource(
           crd, Broadcast.class, BroadcastList.class, 1 * 60 * 1000);

    informer.addEventHandler(handler);
    factory.startAllRegisteredInformers();   
  }
}
Listing 5.

Om te reageren op de events die de informer stuurt, moeten we ook een ResourceEventHandler schrijven (Listing 6). Hier gebeurt het echte werk en krijgen we drie verschillende events; add, update en delete.

public class EventHandler implements ResourceEventHandler<Broadcast> {

    @Override
    public void onAdd(final Broadcast broadcast) {
        websocket.addBroadcast(broadcast.getSpec());
    }

    @Override
    public void onUpdate(Broadcast oldb, Broadcast newb) {
        if(!oldb.getMetadata().getResourceVersion()
              .equals(newb.getMetadata().getResourceVersion()) )
        {
            websocket.deleteBroadcast(oldb.getSpec());
            websocket.addBroadcast(newb.getSpec());   
        }
    }

    @Override
    public void onDelete(Broadcast broadcast,
                         boolean deletedFinalStateUnknown) {
        websocket.deleteBroadcast(broadcast.getSpec());
    }
}
Listing 6.

Bij het onUpdate event krijgen we de vorig bekende versie van onze resource en de nieuwe versie van de resource binnen. Deze kunnen gelijk zijn en om die reden vergelijken we de versie van de resource of deze inderdaad is veranderd.

De onDelete methode krijgt ook een deletedFinalStateUnknown mee. Deze geeft aan of het geleverde CRD object de state beschrijft tijdens het delete event. Het kan gebeuren dat het daadwerkelijke delete event is gemist door de cliënt, maar dat bij de reconciliation is gebleken dat het object niet meer bestaat. In dat geval geeft het de laatst bekende versie van het object, wat kan verschillen met de versie tijdens het delete event.

Onze informer implementatie roept vervolgens code aan om de broadcasts middels een websocket naar de eindgebruiker te sturen. Mocht je nieuwsgierig zijn naar deze implementatie, kan je naar de repository op GitHub gaan [5] waar dit voorbeeld in zijn geheel is uitgewerkt.

Verdere toepassingen

Dit was natuurlijk een makkelijk voorbeeld. Als onze business logica verder gaat dan alleen een banner afbeelden, bijvoorbeeld wanneer er integraties aan te pas komen, kan er ook meer misgaan. Er komt dan al snel een behoefte voor een retry-mechaniek. De fabric8 client helpt ons hier niet heel veel mee en moeten we een retry zelf moeten. Heel moeilijk is dit ook niet, de fabric8 library vuurt regelmatig ‘updated’ events af, ook al is de resourceVersion niet gewijzigd. Dit kan gebruikt worden voor een retry-implementatie.

Een ander veel voorkomend probleem, is dat sommige resources maar precies een keer verwerkt moeten of zelfs kunnen worden. Als er meerdere replica’s draaien, weten deze services niet van elkaar wie welke resource heeft verwerkt. Om dit makkelijker te maken, heeft de fabric8 Kubernetes client leader-election support. Het is dan aan de service zelf of alleen de leader de resource verwerkt, of deze via een ander mechanisme distribueert.

Met dit artikel hebben we gezien dat het niet moeilijk is om een Java-applicatie nog meer cloud native te maken door gebruik te maken van de Kubernetes API. Kubernetes is ons nieuwe operating systeem, dus laten we die optimaal gebruiken! CRD’s en de Kubernetes API zijn een mooi stukje gereedschap om bij ons te dragen in het oplossen van onze dagelijkse problemen.

Referenties

[1] https://kubernetes.io/docs/home/

[2] https://minikube.sigs.k8s.io/docs/

[3] https://quarkus.io

[4] https://github.com/fabric8io/kubernetes-client

[5] https://github.com/joyrex2001/quarkus-crddemo

Biografie

Vincent van Dam is werkzaam bij HCS Company en is constant op zoek naar avontuur en kennis. In zijn vrije tijd reist hij het liefst de hele wereld rond.