Kapitel 1. Einführung

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Die Programmierung von Kubernetes kann für verschiedene Menschen unterschiedliche Dinge bedeuten. In diesem Kapitel legen wir zunächst den Umfang und den Schwerpunkt dieses Buches fest. Außerdem gehen wir auf die Umgebung ein, in der wir arbeiten, und auf die Voraussetzungen, die du idealerweise mitbringen solltest, um von diesem Buch zu profitieren. Wir werden definieren, was genau wir mit der Programmierung von Kubernetes meinen, was Kubernetes-native Apps sind und anhand eines konkreten Beispiels zeigen, was ihre Merkmale sind. Wir besprechen die Grundlagen von Controllern und Operatoren und wie die ereignisgesteuerte Kubernetes-Kontrollebene im Prinzip funktioniert. Bist du bereit? Dann lass uns loslegen.

Was bedeutet es, Kubernetes zu programmieren?

Wir gehen davon aus, dass du Zugang zu einem laufenden Kubernetes-Cluster wie Amazon EKS, Microsoft AKS, Google GKE oder einem der OpenShift-Angebote hast.

Tipp

Du verbringst einen Großteil deiner Zeit mit lokaler Entwicklung auf deinem Laptop oder Desktop, d.h. der Kubernetes-Cluster, gegen den du entwickelst, ist lokal und nicht in der Cloud oder in deinem Rechenzentrum. Wenn du lokal entwickelst, hast du eine Reihe von Optionen zur Verfügung. Je nach Betriebssystem und anderen Vorlieben kannst du dich für eine (oder vielleicht sogar mehrere) der folgenden Lösungen entscheiden, um Kubernetes lokal auszuführen: kind, k3d oder Docker Desktop.1

Wir gehen außerdem davon aus, dass du ein Go-Programmierer bist, d.h. du hast Erfahrung mit der Programmiersprache Go oder bist zumindest mit ihr vertraut. Wenn eine dieser Annahmen nicht auf dich zutrifft, ist jetzt ein guter Zeitpunkt, um dich weiterzubilden: Für Go empfehlen wir The Go Programming Language von Alan A. A. Donovan und Brian W. Kernighan (Addison-Wesley) und Concurrency in Go von Katherine Cox-Buday (O'Reilly). Für Kubernetes solltest du dir eines oder mehrere der folgenden Bücher ansehen:

Hinweis

Warum konzentrieren wir uns auf die Programmierung von Kubernetes in Go? Nun, eine Analogie könnte hier hilfreich sein: Unix wurde in der Programmiersprache C geschrieben, und wenn du Anwendungen oder Werkzeuge für Unix schreiben wolltest, würdest du standardmäßig C verwenden. Auch um Unix zu erweitern und anzupassen - selbst wenn du eine andere Sprache als C verwenden würdest - müsstest du zumindest C lesen können.

Heute sind Kubernetes und viele verwandte Cloud-native Technologien, von Container-Laufzeiten bis hin zu Monitoring wie Prometheus, in Go geschrieben. Wir gehen davon aus, dass die meisten nativen Anwendungen auf Go basieren werden und konzentrieren uns daher in diesem Buch auf Go. Wenn du andere Sprachen bevorzugst, solltest du die kubernetes-client GitHub Organisation im Auge behalten. Möglicherweise wird sie in Zukunft einen Client in deiner bevorzugten Programmiersprache enthalten.

Mit "Kubernetes programmieren" meinen wir in diesem Buch Folgendes: Du bist dabei, eine Kubernetes-native Anwendung zu entwickeln, die direkt mit dem API-Server interagiert und den Zustand von Ressourcen abfragt und/oder ihren Zustand aktualisiert. Wir meinen damit nicht das Ausführen von Anwendungen von der Stange, wie WordPress oder Rocket Chat oder dein Lieblings-CRM-System für Unternehmen, die oft auch als kommerziell verfügbare Anwendungen bezeichnet werden. Außerdem konzentrieren wir uns in Kapitel 7 nicht so sehr auf betriebliche Aspekte, sondern befassen uns hauptsächlich mit der Entwicklungs- und Testphase. Kurz gesagt, geht es in diesem Buch um die Entwicklung von wirklich cloud-nativen Anwendungen. Abbildung 1-1 hilft dir vielleicht, das besser zu verstehen.

Different types of apps running on Kubernetes
Abbildung 1-1. Verschiedene Arten von Anwendungen, die auf Kubernetes laufen

Auf kannst du sehen, dass dir verschiedene Stile zur Verfügung stehen:

  1. Nimm ein COTS wie Rocket Chat und lass es auf Kubernetes laufen. Die App selbst weiß nicht, dass sie auf Kubernetes läuft und muss es normalerweise auch nicht. Kubernetes steuert den Lebenszyklus der Anwendung - Node finden, Image ziehen, Container starten, Gesundheitschecks durchführen, Volumes mounten und so weiter - und das war's.

  2. Nimm eine maßgeschneiderte Anwendung, die du von Grund auf geschrieben hast, mit oder ohne Kubernetes als Laufzeitumgebung im Hinterkopf, und lass sie auf Kubernetes laufen. Es gilt die gleiche Vorgehensweise wie bei einer COTS-Anwendung.

  3. Der Fall, auf den wir uns in diesem Buch konzentrieren, ist eine Cloud-native oder Kubernetes-native Anwendung, die sich bewusst ist, dass sie auf Kubernetes läuft und Kubernetes-APIs und -Ressourcen bis zu einem gewissen Grad nutzt.

Der Preis, den du für die Entwicklung gegen die Kubernetes-API zahlst, zahlt sich aus: Einerseits gewinnst du an Portabilität, da deine App jetzt in jeder Umgebung läuft (von einem On-Premises-Deployment bis zu jedem öffentlichen Cloud-Provider), und andererseits profitierst du von dem sauberen, deklarativen Mechanismus, den Kubernetes bietet.

Lass uns jetzt zu einem konkreten Beispiel übergehen.

Ein motivierendes Beispiel

Um die Leistungsfähigkeit einer Kubernetes-nativen App zu demonstrieren, nehmen wir an, du möchtest atimplementieren, d.h. die Ausführung eines Befehls zu einer bestimmten Zeit planen.

Wir nennen dies cnat oder cloud-native at, und es funktioniert wie folgt. Nehmen wir an, du willst den Befehl echo "Kubernetes native rocks!" am 3. Juli 2019 um 2 Uhr nachts ausführen. Das würdest du mit cnat machen:

$ cat cnat-rocks-example.yaml
apiVersion: cnat.programming-kubernetes.info/v1alpha1
kind: At
metadata:
  name: cnrex
spec:
  schedule: "2019-07-03T02:00:00Z"
  containers:
  - name: shell
    image: centos:7
    command:
    - "bin/bash"
    - "-c"
    - echo "Kubernetes native rocks!"

$ kubectl apply -f cnat-rocks-example.yaml
cnat.programming-kubernetes.info/cnrex created

Hinter den Kulissen sind die folgenden Komponenten beteiligt:

  • Eine benutzerdefinierte Ressource namens cnat.programming-kubernetes.info/cnrex, die den Zeitplan darstellt.

  • Ein Controller, der den geplanten Befehl zur richtigen Zeit ausführt.

Außerdem wäre ein kubectl Plug-in für die CLI UX nützlich, das eine einfache Handhabung über Befehle wie kubectl at "02:00 Jul 3" echo "Kubernetes native rocks!" ermöglicht. Wir werden das in diesem Buch nicht schreiben, aber du kannst in der Kubernetes-Dokumentation nachsehen , wie es geht.

Im Laufe des Buches werden wir dieses Beispiel nutzen, um Aspekte von Kubernetes, sein Innenleben und seine Erweiterungsmöglichkeiten zu diskutieren.

Für die fortgeschritteneren Beispiele in den Kapiteln 8 und 9 werden wir ein Pizzarestaurant mit Pizza- und Belagobjekten im Cluster simulieren. Siehe "Beispiel: Ein Pizza-Restaurant" für Details.

Muster für Erweiterungen

Kubernetes ist ein leistungsstarkes und von Natur aus erweiterbares System. Im Allgemeinen gibt es mehrere Möglichkeiten, Kubernetes anzupassen und/oder zu erweitern: mithilfe von Konfigurationsdateien und Flags für Control-Plane-Komponenten wie dem kubelet oder dem Kubernetes-API-Server sowie über eine Reihe von definierten Erweiterungspunkten:

Im Rahmen dieses Buches konzentrieren wir uns auf benutzerdefinierte Ressourcen, Controller, Webhooks und benutzerdefinierte API-Server sowie die Kubernetes-Erweiterungsmuster. Wenn du dich für andere Erweiterungspunkte wie Speicherung oder Netzwerk-Plug-ins interessierst, schau dir die offizielle Dokumentation an.

Nachdem du nun ein grundlegendes Verständnis der Kubernetes-Erweiterungsmuster und des Umfangs dieses Buches hast, wollen wir uns nun dem Herzstück der Kubernetes-Kontrollebene zuwenden und sehen, wie wir sie erweitern können.

Kontrolleure und Bediener

Unter erfährst du in diesem Abschnitt mehr über Controller und Operatoren in Kubernetes und wie sie funktionieren.

Laut dem Kubernetes-Glossar implementiert ein Controller einen Regelkreis, der den gemeinsamen Zustand des Clusters über den API-Server überwacht und Änderungen vornimmt, um den aktuellen Zustand in Richtung des gewünschten Zustands zu bewegen.

Bevor wir in das Innenleben des Controllers eintauchen, sollten wir unsere Terminologie definieren:

  • Controller können auf Kernressourcen wie Deployments oder Services einwirken, die in der Regel Teil des Kubernetes Controller Managers in der Control Plane sind, oder sie können benutzerdefinierte Ressourcen überwachen und manipulieren.

  • Operatoren sind Controller, die zusammen mit den in Kapitel 4 definierten benutzerdefinierten Ressourcen operatives Wissen, wie z. B. das Application Lifecycle Management, kodieren.

Da das letztgenannte Konzept auf dem erstgenannten basiert, werden wir uns natürlich zuerst mit Controllern befassen und dann den spezielleren Fall eines Operators diskutieren.

Der Regelkreis

In allgemein sieht der Regelkreis wie folgt aus:

  1. Lies den Status von Ressourcen, vorzugsweise ereignisgesteuert (mit Watches, wie in Kapitel 3 beschrieben). Weitere Informationen findest du unter "Ereignisse" und "Kanten- versus Level-gesteuerte Auslöser".

  2. Ändere den Status von Objekten im Cluster oder in der cluster-externen Welt. Du kannst zum Beispiel einen Pod starten, einen Netzwerkendpunkt erstellen oder eine Cloud-API abfragen. Weitere Informationen findest du unter "Ändern von Cluster-Objekten oder der externen Welt".

  3. Aktualisiere den Status der Ressource in Schritt 1 über den API-Server in etcd. Siehe "Optimistische Gleichzeitigkeit" für weitere Details.

  4. Wiederhole den Zyklus und gehe zurück zu Schritt 1.

Egal wie komplex oder einfach dein Controller ist, diese drei Schritte - Ressourcenzustand lesen ˃ die Welt verändern ˃ Ressourcenstatus aktualisieren - bleiben gleich. Schauen wir uns etwas genauer an, wie diese Schritte in einem Kubernetes-Controller implementiert sind. Der Regelkreis ist in Abbildung 1-2 dargestellt, die die typischen beweglichen Teile zeigt, mit der Hauptschleife des Controllers in der Mitte. Diese Hauptschleife läuft kontinuierlich innerhalb des Controller-Prozesses. Dieser Prozess läuft normalerweise innerhalb eines Pods im Cluster.

Kubernetes control loop
Abbildung 1-2. Kubernetes Regelkreis

Aus architektonischer Sicht verwendet ein Controller in der Regel die folgenden Datenstrukturen (wie in Kapitel 3 ausführlich beschrieben):

Informanten

Informer überwachen den gewünschten Zustand der Ressourcen auf eine skalierbare und nachhaltige Weise. Sie implementieren auch einen Resync-Mechanismus (siehe "Informer und Caching" ), der einen regelmäßigen Abgleich erzwingt und oft verwendet wird, um sicherzustellen, dass der Clusterzustand und der angenommene Zustand im Cache nicht auseinanderdriften (z. B. aufgrund von Fehlern oder Netzwerkproblemen).

Warteschlangen bei der Arbeit

Im Wesentlichen ist eine work queue eine Komponente, die vom Event-Handler verwendet werden kann, um Zustandsänderungen in eine Warteschlange zu stellen und Wiederholungen zu ermöglichen. In client-go ist diese Funktionalität über das workqueue-Paket verfügbar (siehe "Work Queue"). Ressourcen können erneut in die Warteschlange gestellt werden, wenn beim Aktualisieren der Welt oder beim Schreiben des Status (Schritte 2 und 3 in der Schleife) Fehler auftreten oder weil wir die Ressource nach einiger Zeit aus anderen Gründen erneut betrachten müssen.

Für eine formalere Diskussion über Kubernetes als deklarative Engine und Zustandsübergänge, lies "The Mechanics of Kubernetes" von Andrew Chen und Dominik Tornow.

Schauen wir uns nun den Regelkreis genauer an und beginnen mit der ereignisgesteuerten Architektur von Kubernetes.

Veranstaltungen

Die Kubernetes-Kontrollebene setzt stark auf Ereignisse und das Prinzip der lose gekoppelten Komponenten. Andere verteilte Systeme verwenden Remote Procedure Calls (RPCs), um Verhalten auszulösen. Kubernetes tut dies nicht. Kubernetes-Controller beobachten Änderungen an Kubernetes-Objekten im API-Server: Hinzufügen, Aktualisieren und Entfernen. Wenn ein solches Ereignis eintritt, führt der Controller seine Geschäftslogik aus.

Um zum Beispiel einen Pod über ein Deployment zu starten, arbeiten eine Reihe von Controllern und anderen Komponenten der Steuerungsebene zusammen:

  1. Der Deployment Controller (innerhalb von kube-controller-manager) bemerkt (über einen Deployment Informer), dass der Benutzer ein Deployment erstellt. Er erstellt in seiner Geschäftslogik ein Replikat-Set.

  2. Der Replikat-Controller (wiederum innerhalb von kube-controller-manager) bemerkt (über einen Replikat-Informer) das neue Replikat und führt anschließend seine Geschäftslogik aus, die ein Pod-Objekt erstellt.

  3. Das Zeitplannungsprogramm (in der Binärdatei kube-scheduler ) - das auch ein Controller ist - benachrichtigt den Pod (über einen Pod-Informer) mit einem leeren spec.nodeName Feld. Die Geschäftslogik des Zeitplannungsprogramms stellt den Pod in die Warteschlange.

  4. In der Zwischenzeit bemerkt der kubelet- ein anderer Controller - den neuen Pod (über seinen Pod-Informer). Das Feld spec.nodeName des neuen Pods ist jedoch leer und stimmt daher nicht mit dem Knotennamen von kubeletüberein. Er ignoriert den Pod und geht zurück in den Schlafmodus (bis zum nächsten Ereignis).

  5. Das Zeitplannungsprogramm nimmt den Pod aus der Arbeitswarteschlange und plant ihn für einen Knoten ein, der über genügend freie Ressourcen verfügt, indem es das Feld spec.nodeName im Pod aktualisiert und es auf den API-Server schreibt.

  6. Die kubelet wacht aufgrund des Pod-Aktualisierungsereignisses wieder auf. Er vergleicht erneut den spec.nodeName mit seinem eigenen Knotennamen. Die Namen stimmen überein und so startet kubelet die Container des Pods und meldet dem API-Server zurück, dass die Container gestartet wurden, indem es diese Information in den Pod-Status schreibt.

  7. Der Replikat-Set-Controller bemerkt den geänderten Pod, kann aber nichts tun.

  8. Irgendwann wird der Pod beendet. kubelet bemerkt dies, holt das Pod-Objekt vom API-Server und setzt die Bedingung "beendet" im Status des Pods und schreibt es zurück zum API-Server.

  9. Der Replikat-Controller bemerkt den beendeten Pod und entscheidet, dass dieser Pod ersetzt werden muss. Er löscht den beendeten Pod auf dem API-Server und erstellt einen neuen.

  10. Und so weiter.

Wie du siehst, kommunizieren eine Reihe unabhängiger Kontrollschleifen ausschließlich über Objektänderungen auf dem API-Server und Ereignisse, die diese Änderungen über Informanten auslösen.

Diese Ereignisse werden vom API-Server über Watches (siehe "Watches") an die Informanten in den Controllern gesendet, d. h. über Streaming-Verbindungen von Watch-Ereignissen. All dies ist für den Nutzer weitgehend unsichtbar. Nicht einmal der Audit-Mechanismus des API-Servers macht diese Ereignisse sichtbar; nur die Objektaktualisierungen sind sichtbar. Controller nutzen jedoch oft die Log-Ausgabe, wenn sie auf Ereignisse reagieren.

Wenn du mehr über Events erfahren möchtest, lies Michael Gaschs Blogbeitrag "Events, the DNA of Kubernetes", in dem er mehr Hintergrundinformationen und Beispiele liefert.

Kanten- versus Level-gesteuerte Auslöser

Gehen wir einen Schritt zurück ( ) und betrachten wir abstrakter, wie wir die in Controllern implementierte Geschäftslogik strukturieren können und warum Kubernetes sich dafür entschieden hat, Ereignisse (d. h. Zustandsänderungen) zur Steuerung seiner Logik zu verwenden.

Es gibt zwei prinzipielle Möglichkeiten, Zustandsänderungen (das Ereignis selbst) zu erkennen:

Kantengesteuerte Auslöser

Unter wird zum Zeitpunkt der Zustandsänderung ein Handler ausgelöst, z. B. von "kein Pod" zu "Pod läuft".

Level-gesteuerte Auslöser

Der Status wird in regelmäßigen Abständen auf überprüft und wenn bestimmte Bedingungen erfüllt sind (z.B. Pod läuft), wird ein Handler ausgelöst.

Die ist eine Form des Pollings. Sie lässt sich nicht gut mit der Anzahl der Objekte skalieren, und die Latenzzeit der Controller, die Änderungen bemerken, hängt vom Intervall der Abfrage und davon ab, wie schnell der API-Server antworten kann. Wenn viele asynchrone Controller beteiligt sind, wie in "Ereignisse" beschrieben , ist das Ergebnis ein System, das lange braucht, um die Wünsche der Nutzer umzusetzen.

Die erste Option ist bei vielen Objekten viel effizienter. Die Latenzzeit hängt hauptsächlich von der Anzahl der Worker-Threads ab, die die Ereignisse des Controllers verarbeiten. Daher basiert Kubernetes auf Ereignissen (d.h. auf Kanten-gesteuerten Triggern).

Auf ändert eine Reihe von Komponenten Objekte auf dem API-Server, wobei jede Änderung zu einem Ereignis (d.h. einer Kante) führt. Wir nennen diese Komponenten Ereignisquellen oder Ereignisproduzenten. Im Zusammenhang mit Controllern sind wir hingegen daran interessiert, Ereignisse zu konsumieren, d. h. zu wissen, wann und wie auf ein Ereignis reagiert werden soll (über einen Informator).

In einem verteilten System gibt es viele Akteure, die parallel arbeiten, und die Ereignisse treffen asynchron in beliebiger Reihenfolge ein. Wenn die Controller-Logik fehlerhaft ist, der Zustandsautomat nicht richtig funktioniert oder ein externer Dienst ausfällt, kann es leicht passieren, dass Ereignisse nicht vollständig verarbeitet werden. Deshalb müssen wir uns genauer ansehen, wie mit Fehlern umgehen kann.

In Abbildung 1-3 siehst du verschiedene Strategien bei der Arbeit:

  1. Ein Beispiel für eine rein kantengesteuerte Logik, bei der möglicherweise die zweite Zustandsänderung verpasst wird.

  2. Ein Beispiel für eine flankengetriggerte Logik, die bei der Verarbeitung eines Ereignisses immer den neuesten Zustand (d.h. den Pegel) erhält. Mit anderen Worten: Die Logik ist flankengetriggert, aber pegelgesteuert.

  3. Ein Beispiel für eine flankengetriggerte, pegelgesteuerte Logik mit zusätzlicher Resynchronisation.

Trigger options (edge vs. level)
Abbildung 1-3. Triggeroptionen (Kanten- und Pegelgesteuert)

Strategie 1 kommt nicht gut mit verpassten Ereignissen zurecht, sei es, weil ein defektes Netzwerk dazu führt, dass Ereignisse verloren gehen, oder weil der Controller selbst Fehler hat oder eine externe Cloud-API ausgefallen ist. Stell dir vor, der Replikat-Controller würde Pods nur dann ersetzen, wenn sie ausfallen. Fehlende Ereignisse würden bedeuten, dass das Replikat-Set immer mit weniger Pods laufen würde, weil es nie den gesamten Status abgleicht.

Strategie 2 behebt diese Probleme, wenn ein weiteres Ereignis eintrifft, da sie ihre Logik auf der Grundlage des letzten Zustands im Cluster implementiert. Der Replikat-Controller vergleicht immer die angegebene Anzahl der Replikate mit den laufenden Pods im Cluster. Wenn er Ereignisse verliert, ersetzt er alle fehlenden Pods beim nächsten Eingang einer Pod-Aktualisierung.

Strategie 3 fügt eine kontinuierliche Neusynchronisierung hinzu (z. B. alle fünf Minuten). Wenn keine Pod-Ereignisse eintreffen, findet zumindest alle fünf Minuten ein Abgleich statt, auch wenn die Anwendung sehr stabil läuft und nicht zu vielen Pod-Ereignissen führt.

Angesichts der Herausforderungen, die reine Kanten-Trigger mit sich bringen, setzen die Kubernetes-Controller in der Regel die dritte Strategie um.

Wenn du mehr über die Ursprünge der Trigger und die Beweggründe für Level Triggering mit Reconciliation in Kubernetes erfahren möchtest, lies den Artikel von James Bowes, "Level Triggering and Reconciliation in Kubernetes".

Damit ist die Diskussion über die verschiedenen, abstrakten Möglichkeiten, externe Veränderungen zu erkennen und darauf zu reagieren, abgeschlossen. Der nächste Schritt im Regelkreis von Abbildung 1-2 besteht darin, die Clusterobjekte zu verändern oder die externe Welt nach den Vorgaben zu verändern. Das werden wir uns jetzt ansehen.

Ändern von Cluster-Objekten oder der externen Welt

In dieser Phase ändert der Controller den Zustand der Objekte, die er überwacht. Zum Beispiel überwacht der ReplicaSet Controller im Controller Manager Pods. Bei jedem Ereignis (Kanten-getriggert) beobachtet er den aktuellen Zustand seiner Pods und vergleicht ihn mit dem gewünschten Zustand (Level-gesteuert).

Da die Änderung des Ressourcenzustands domänen- oder aufgabenspezifisch ist, können wir nur wenig Anleitung geben. Stattdessen schauen wir uns weiterhin den ReplicaSet Controller an, den wir bereits vorgestellt haben. ReplicaSets werden in Deployments verwendet, und die Quintessenz des jeweiligen Controllers lautet: eine benutzerdefinierte Anzahl von identischen Pod Replikaten aufrechtzuerhalten. Das heißt, wenn es weniger Pods gibt, als der Nutzer angegeben hat - zum Beispiel, weil ein Pod gestorben ist oder der Replikat-Wert erhöht wurde - startet der Controller neue Pods. Wenn es jedoch zu viele Pods gibt, wählt er einige zur Beendigung aus. Die gesamte Geschäftslogik des Controllers ist über das Paket replica_set.go verfügbar, und der folgende Auszug aus dem Go-Code befasst sich mit der Zustandsänderung (der Übersichtlichkeit halber bearbeitet):

// manageReplicas checks and updates replicas for the given ReplicaSet.
// It does NOT modify <filteredPods>.
// It will requeue the replica set in case of an error while creating/deleting pods.
func (rsc *ReplicaSetController) manageReplicas(
	filteredPods []*v1.Pod, rs *apps.ReplicaSet,
) error {
    diff := len(filteredPods) - int(*(rs.Spec.Replicas))
    rsKey, err := controller.KeyFunc(rs)
    if err != nil {
        utilruntime.HandleError(
        	fmt.Errorf("Couldn't get key for %v %#v: %v", rsc.Kind, rs, err),
        )
        return nil
    }
    if diff < 0 {
        diff *= -1
        if diff > rsc.burstReplicas {
            diff = rsc.burstReplicas
        }
        rsc.expectations.ExpectCreations(rsKey, diff)
        klog.V(2).Infof("Too few replicas for %v %s/%s, need %d, creating %d",
        	rsc.Kind, rs.Namespace, rs.Name, *(rs.Spec.Replicas), diff,
        )
        successfulCreations, err := slowStartBatch(
        	diff,
        	controller.SlowStartInitialBatchSize,
        	func() error {
        		ref := metav1.NewControllerRef(rs, rsc.GroupVersionKind)
                err := rsc.podControl.CreatePodsWithControllerRef(
            	    rs.Namespace, &rs.Spec.Template, rs, ref,
                )
                if err != nil && errors.IsTimeout(err) {
                	return nil
                }
                return err
            },
        )
        if skippedPods := diff - successfulCreations; skippedPods > 0 {
            klog.V(2).Infof("Slow-start failure. Skipping creation of %d pods," +
            	" decrementing expectations for %v %v/%v",
            	skippedPods, rsc.Kind, rs.Namespace, rs.Name,
            )
            for i := 0; i < skippedPods; i++ {
                rsc.expectations.CreationObserved(rsKey)
            }
        }
        return err
    } else if diff > 0 {
        if diff > rsc.burstReplicas {
            diff = rsc.burstReplicas
        }
        klog.V(2).Infof("Too many replicas for %v %s/%s, need %d, deleting %d",
        	rsc.Kind, rs.Namespace, rs.Name, *(rs.Spec.Replicas), diff,
        )

        podsToDelete := getPodsToDelete(filteredPods, diff)
        rsc.expectations.ExpectDeletions(rsKey, getPodKeys(podsToDelete))
        errCh := make(chan error, diff)
        var wg sync.WaitGroup
        wg.Add(diff)
        for _, pod := range podsToDelete {
            go func(targetPod *v1.Pod) {
                defer wg.Done()
                if err := rsc.podControl.DeletePod(
                	rs.Namespace,
                	targetPod.Name,
                	rs,
                ); err != nil {
                    podKey := controller.PodKey(targetPod)
                    klog.V(2).Infof("Failed to delete %v, decrementing " +
                    	"expectations for %v %s/%s",
                    	podKey, rsc.Kind, rs.Namespace, rs.Name,
                    )
                    rsc.expectations.DeletionObserved(rsKey, podKey)
                    errCh <- err
                }
            }(pod)
        }
        wg.Wait()

        select {
        case err := <-errCh:
            if err != nil {
                return err
            }
        default:
        }
    }
    return nil
}

Du kannst sehen, dass der Kontrolleur den Unterschied zwischen der Vorgabe und dem aktuellen Zustand in der Zeile diff := len(filteredPods) - int(*(rs.Spec.Replicas)) berechnet und dann zwei davon abhängige Fälle implementiert:

  • diff < 0 Zu wenige Replikate; es müssen mehr Pods erstellt werden.

  • diff > 0: Zu viele Replikate; Pods müssen gelöscht werden.

Außerdem wird eine Strategie implementiert, um Pods auszuwählen, bei denen es am wenigsten schädlich ist, sie in getPodsToDelete zu löschen.

Das Ändern des Ressourcenzustands bedeutet jedoch nicht zwangsläufig, dass die Ressourcen selbst Teil des Kubernetes-Clusters sein müssen. Mit anderen Worten: Ein Controller kann den Status von Ressourcen ändern, die sich außerhalb von Kubernetes befinden, wie z. B. ein Cloud-Speicherdienst. Mit dem AWS Service Operator kannst du zum Beispiel AWS-Ressourcen verwalten. Unter anderem kannst du damit S3-Buckets verwalten - das heißt, der S3-Controller überwacht eine Ressource (den S3-Bucket), die außerhalb von Kubernetes existiert, und die Zustandsänderungen spiegeln konkrete Phasen in ihrem Lebenszyklus wider: Ein S3-Bucket wird erstellt und irgendwann gelöscht.

Das sollte dich davon überzeugen, dass du mit einem benutzerdefinierten Controller nicht nur Kernressourcen wie Pods und benutzerdefinierte Ressourcen wie in unserem Beispiel cnat verwalten kannst, sondern sogar Rechen- oder Speicherressourcen, die außerhalb von Kubernetes existieren. Das macht Controller zu sehr flexiblen und leistungsstarken Integrationsmechanismen, die eine einheitliche Nutzung von Ressourcen über Plattformen und Umgebungen hinweg ermöglichen.

Optimistische Gleichzeitigkeit

In "The Control Loop" haben wir in Schritt 3 besprochen, dass ein Controller - nachdem er die Clusterobjekte und/oder die externe Welt gemäß der Spezifikation aktualisiert hat - die Ergebnisse in den Status der Ressource schreibt, die den Controllerlauf in Schritt 1 ausgelöst hat.

Dieser und eigentlich jeder andere Schreibvorgang (auch in Schritt 2) kann schiefgehen. In einem verteilten System ist dieser Controller wahrscheinlich nur einer von vielen, die Ressourcen aktualisieren. Gleichzeitige Schreibvorgänge können aufgrund von Schreibkonflikten fehlschlagen.

Um besser zu verstehen, was hier passiert, lass uns einen Schritt zurücktreten und einen Blick auf Abbildung 1-4 werfen.2

Scheduling architectures in distributed systems
Abbildung 1-4. Zeitplanungsprogramm-Architekturen in verteilten Systemen

Der Quelltext definiert die Architektur des parallelen Zeitplannungsprogramms von Omega wie folgt:

Unsere Lösung ist ein neues paralleles Zeitplannungsprogramm, das auf einem gemeinsam genutzten Zustand aufbaut und eine sperrfreie optimistische Gleichzeitigkeitskontrolle verwendet, um sowohl die Erweiterbarkeit der Implementierung als auch die Skalierbarkeit der Leistung zu erreichen. Diese Architektur wird in Omega, Googles Cluster-Management-System der nächsten Generation, eingesetzt.

Während Kubernetes viele Eigenschaften und Lektionen von Borg geerbt hat, stammt diese spezielle Funktion der transaktionalen Kontrollebene von Omega: Um gleichzeitige Operationen ohne Sperren durchzuführen, verwendet der Kubernetes-API-Server optimistische Nebenläufigkeit.

Das bedeutet kurz gesagt, dass der API-Server, wenn er gleichzeitige Schreibversuche feststellt, den letzteren der beiden Schreibvorgänge ablehnt. Es ist dann Sache des Clients (Controller, Zeitplannungsprogramm, kubectl usw.), einen Konflikt zu lösen und den Schreibvorgang möglicherweise zu wiederholen.

Im Folgenden wird die Idee der optimistischen Gleichzeitigkeit in Kubernetes demonstriert:

var err error
for retries := 0; retries < 10; retries++ {
    foo, err = client.Get("foo", metav1.GetOptions{})
    if err != nil {
        break
    }

    <update-the-world-and-foo>

    _, err = client.Update(foo)
    if err != nil && errors.IsConflict(err) {
        continue
    } else if err != nil {
        break
    }
}

Der Code zeigt eine Wiederholungsschleife, die in jeder Iteration das neueste Objekt foo abruft und dann versucht, die Welt und den Status von foozu aktualisieren, damit er mit den Angaben von fooübereinstimmt. Die Änderungen, die vor dem Aufruf von Update vorgenommen wurden, sind optimistisch.

Das zurückgegebene Objekt foo aus dem client.Get Aufruf enthält eine Ressourcenversion (Teil der eingebetteten ObjectMeta Struktur - siehe "ObjectMeta" für Details), die etcd bei der Schreiboperation hinter dem client.Update Aufruf mitteilt, dass ein anderer Akteur im Cluster das foo Objekt in der Zwischenzeit geschrieben hat. Wenn das der Fall ist, wird unsere Wiederholungsschleife einen Fehler bezüglich eines Ressourcenversionskonflikts erhalten. Das bedeutet, dass die optimistische Gleichzeitigkeitslogik fehlgeschlagen ist. Mit anderen Worten: Der Aufruf von client.Update ist ebenfalls optimistisch.

Hinweis

Die Ressourcenversion ist eigentlich die etcd Schlüssel/Wert-Version. Die Ressourcenversion eines jeden Objekts ist in Kubernetes ein String, der eine ganze Zahl enthält. Diese Ganzzahl kommt direkt von etcd. etcd verwaltet einen Zähler, der jedes Mal erhöht wird, wenn der Wert eines Schlüssels (der die Serialisierung des Objekts enthält) geändert wird.

Im gesamten API-Maschinencode wird die Ressourcenversion (mehr oder weniger konsequent) wie eine beliebige Zeichenkette behandelt, allerdings mit einer gewissen Ordnung. Die Tatsache, dass Ganzzahlen gespeichert werden, ist nur ein Implementierungsdetail des aktuellen etcd Speicher-Backends.

Schauen wir uns ein konkretes Beispiel an. Stell dir vor, dein Client ist nicht der einzige Akteur im Cluster, der einen Pod verändert. Es gibt einen weiteren Akteur, nämlich den kubelet, der ständig einige Felder ändert, weil ein Container ständig abstürzt. Nun liest dein Controller den aktuellen Status des Pod-Objekts wie folgt:

kind: Pod
metadata:
  name: foo
  resourceVersion: 57
spec:
  ...
status:
  ...

Nehmen wir nun an, dass der Controller mehrere Sekunden für seine Aktualisierungen der Welt braucht. Sieben Sekunden später versucht er, den Pod zu aktualisieren, den er gelesen hat - zum Beispiel, indem er eine Annotation setzt. In der Zwischenzeit hat kubelet einen weiteren Neustart des Containers bemerkt und den Status des Pods entsprechend aktualisiert, d.h. resourceVersion ist auf 58 gestiegen.

Das Objekt, das dein Controller in der Aktualisierungsanfrage sendet, hat resourceVersion: 57. Der API-Server versucht, den etcd -Schlüssel für den Pod auf diesen Wert zu setzen. etcd stellt fest, dass die Ressourcenversionen nicht übereinstimmen und meldet zurück, dass 57 mit 58 kollidiert. Die Aktualisierung schlägt fehl.

Die Quintessenz dieses Beispiels ist, dass du für deinen Controller verantwortlich bist, eine Wiederholungsstrategie zu implementieren und dich anzupassen, wenn eine optimistische Operation fehlgeschlagen ist. Du weißt nie, wer sonst noch den Zustand manipuliert, ob andere benutzerdefinierte Controller oder Core-Controller wie der Deployment-Controller.

Die Quintessenz daraus ist: Konfliktfehler sind in Controllern völlig normal. Erwarte sie immer und behandle sie anständig.

Es ist wichtig, darauf hinzuweisen, dass optimistische Gleichzeitigkeit perfekt zu pegelbasierter Logik passt, denn mit pegelbasierter Logik kannst du die Kontrollschleife einfach wiederholen (siehe "Kanten- versus pegelgesteuerte Auslöser"). Bei einem weiteren Durchlauf dieser Schleife werden die optimistischen Änderungen des letzten fehlgeschlagenen optimistischen Versuchs automatisch rückgängig gemacht, und es wird versucht, die Welt auf den neuesten Stand zu bringen.

Kommen wir nun zu einem speziellen Fall von benutzerdefinierten Controllern (zusammen mit benutzerdefinierten Ressourcen): dem Betreiber.

Betreiber

Operatoren wurden 2016 von CoreOS als Konzept in Kubernetes eingeführt. In seinem wegweisenden Blogbeitrag "Introducing Operators: Putting Operational Knowledge into Software" definierte CoreOS CTO Brandon Philips Operatoren wie folgt:

Ein Site Reliability Engineer (SRE) ist eine Person [die] eine Anwendung betreibt, indem sie Software schreibt. Er ist ein Ingenieur, ein Entwickler, der weiß, wie man Software speziell für einen bestimmten Anwendungsbereich entwickelt. Das Ergebnis ist eine Software, in die das Wissen über den Betrieb einer Anwendung einprogrammiert ist.

[...]

Wir nennen diese neue Klasse von Software Operators. Ein Operator ist ein anwendungsspezifischer Controller, der die Kubernetes-API erweitert, um Instanzen komplexer zustandsabhängiger Anwendungen im Namen eines Kubernetes-Nutzers zu erstellen, zu konfigurieren und zu verwalten. Er baut auf den grundlegenden Kubernetes-Ressourcen- und -Controller-Konzepten auf, bezieht aber domänen- oder anwendungsspezifisches Wissen mit ein, um gängige Aufgaben zu automatisieren.

In diesem Buch werden wir die Operatoren so verwenden, wie Philips sie beschreibt. Formal gesehen müssen die folgenden drei Bedingungen erfüllt sein (siehe auch Abbildung 1-5):

  • Es gibt einige bereichsspezifische betriebliche Kenntnisse, die du gerne automatisieren würdest.

  • Die bewährten Methoden für dieses betriebliche Wissen sind bekannt und können explizit gemacht werden - z. B. im Falle eines Cassandra-Operators, wann und wie er die Knoten neu ausbalanciert, oder im Falle eines Operators für ein Service-Mesh, wie er eine Route erstellt.

  • Die Artefakte, die im Zusammenhang mit dem Betreiber geliefert werden, sind:

    • Ein Satz von benutzerdefinierten Ressourcendefinitionen (CRDs), die das domänenspezifische Schema und die auf die CRDs folgenden benutzerdefinierten Ressourcen enthalten, die auf Instanzebene die interessierende Domäne darstellen.

    • Ein benutzerdefinierter Controller, der die benutzerdefinierten Ressourcen überwacht, möglicherweise zusammen mit den Kernressourcen. Der Custom Controller könnte zum Beispiel einen Pod starten.

The concept of an operator
Abbildung 1-5. Das Konzept eines Betreibers

Die Betreiber haben einen langen Weg zurückgelegt, von der konzeptionellen Arbeit und der Entwicklung von Prototypen im Jahr 2016 bis zum Start von OperatorHub.io durch Red Hat (das CoreOS im Jahr 2018 übernahm und die Idee weiter ausbaute) Anfang 2019. Abbildung 1-6 zeigt einen Screenshot des Hubs von Mitte 2019, auf dem 17 Betreiber zu sehen sind, die bereit für die Nutzung sind.

OperatorHub.io screen shot
Abbildung 1-6. OperatorHub.io Bildschirmfoto

Zusammenfassung

In diesem ersten Kapitel haben wir den Umfang unseres Buches definiert und was wir von dir erwarten. Wir haben erklärt, was wir unter der Programmierung von Kubernetes verstehen und haben Kubernetes-native Apps im Kontext dieses Buches definiert. Als Vorbereitung auf die späteren Beispiele haben wir außerdem eine Einführung in Controller und Operatoren gegeben.

Nachdem du nun weißt, was dich in diesem Buch erwartet und wie du davon profitieren kannst, können wir uns ins Getümmel stürzen. Im nächsten Kapitel schauen wir uns die Kubernetes-API genauer an, das Innenleben des API-Servers und wie du mit Befehlszeilen-Tools wie curl mit der API interagieren kannst.

1 Mehr zu diesem Thema findest du in Megan O'Keefes "A Kubernetes Developer Workflow for MacOS", Medium, 24. Januar 2019, und in Alex Ellis' Blogbeitrag "Be KinD to yourself", 14. Dezember 2018.

2 Quelle: "Omega: Flexible, Scalable Schedulers for Large Compute Clusters", von Malte Schwarzkopf et al., Google AI, 2013.

Get Kubernetes programmieren now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.