Architekturdesign von Kubernetes lernen
Ethan Miller
Product Engineer · Leapcell

Duplizierung nicht sofort eliminieren
Wir müssen darauf achten, nicht in einen reaktiven Modus zu verfallen, in dem wir uns gezwungen fühlen, jede Duplizierung, der wir begegnen, sofort zu beseitigen. Es ist wichtig sicherzustellen, dass unsere Maßnahmen zur Beseitigung von Duplizierungen nur auf das abzielen, was im eigentlichen Sinne wirklich doppelt vorhanden ist.
Wenn es zwei Codeabschnitte gibt, die ähnlich aussehen, aber unterschiedlichen Entwicklungspfaden folgen – das heißt, sie haben unterschiedliche Änderungsraten und unterschiedliche Gründe für Änderungen –, dann sind sie nicht wirklich doppelter Code.
Code zu haben, der zukünftigen Code nicht behindert, ist eine äußerst wichtige Fähigkeit, und es dauert oft Jahre, bis man sie beherrscht. Das liegt daran, dass echte Probleme in der Regel nicht beim Ausführen der Software auftreten, sondern während der Entwicklung, Bereitstellung und anschließenden Wartung des Softwaresystems.
Viele Leute, besonders am Anfang, abstrahieren gerne Designs – sie abstrahieren einige Dinge, die in Zukunft gemeinsam genutzt werden könnten. Bei den heutigen schnellen Produktiterationen werden jedoch viele Dinge, die anfangs ähnlich aussehen, letztendlich sehr unterschiedlichen Entwicklungspfaden folgen. In solchen Fällen sind diese Codeabschnitte im eigentlichen Sinne keine Duplikate.
Wenn wir in diesem Stadium mit einem starren Design beginnen, kann dieser Teil des Codes in Zukunft zu einem erheblichen Hindernis werden. Wenn wir unsere ursprünglichen Methoden so granular wie möglich aufschlüsseln und die Benutzer sie selbst zusammenstellen lassen, ist dieser Ansatz möglicherweise nicht für das Schreiben von Middleware mit fester Funktion erforderlich. Für gewöhnliche Geschäftslogik ist er jedoch möglicherweise der bessere Ansatz, da das Wiederverwendungspotenzial höher sein könnte, als eine große, allumfassende Methode zu haben.
Das liegt daran, dass es in einer umfassenden, allumfassenden Funktion zwangsläufig kleinere Unterschiede geben wird, die von neuen Anforderungen nicht benötigt werden. An diesem Punkt gibt es zwei Möglichkeiten: Entweder fügt man der ursprünglichen Methode if-else
-Zweige hinzu, um unterschiedliche Logiken zu behandeln, oder, wenn am Anfang eine Schnittstelle hinterlassen wurde, können verschiedene Implementierungsklassen injiziert werden, um die Aufgabe zu erledigen.
Beide Methoden beinhalten die Modifizierung der ursprünglichen Methode. Wenn ein System zu komplex und veraltet ist und seine Betreuer bereits gewechselt haben (eine Situation, die häufig vorkommt), wissen wir möglicherweise nicht, welche Auswirkungen die Modifikationen haben werden. In diesem Fall ist ein zuverlässigerer Ansatz, den Code-Einstiegspunkt neu zu definieren und kleine, granulare Methoden wiederzuverwenden.
Dies führt jedoch auch zu einem neuen Problem: Methoden mit zu feiner Granularität können verstreut sein, was die Zusammenstellung relativ komplexer macht. Der Vorteil ist jedoch, dass der Prozess kontrollierbarer wird.
In Kubernetes können wir auch viele Methoden und Schnittstellen mit klarer Semantik sehen. Der Vorteil dieses Ansatzes besteht darin, dass er es Neulingen ermöglicht, Implementierungen flexibel zu ersetzen, ohne durch frühere Designs eingeschränkt zu werden. Auf diese Weise müssen wir nicht viel Aufwand betreiben, um die gesamte Logik neu zusammenzusetzen, und wir können bei Bedarf lokale Änderungen vornehmen.
Seien Sie vorsichtig, nicht zu viel zu designen. Fragen Sie sich beim Schreiben immer, warum. Vermeiden Sie es zu designen, bis der Inhalt stabil ist; abstrahieren Sie erst, wenn ähnlicher Inhalt tatsächlich auftaucht. Halten Sie ein Objekt so einfach wie möglich. Beim Refactoring können Sie dann für den gleichen Inhalt Wiederholungen zusammenführen und reduzieren.
Zum Beispiel war die CRI-Abstraktion in Kubernetes nicht von Anfang an vorhanden. Anfangs war Kubernetes stark von DockerManager abhängig und sendete Befehle über HTTP über den Docker Manager an die Docker Engine. Später, um sich von Docker zu entkoppeln und sich an mehr Umgebungen anzupassen, führte Kubernetes in Version 1.5 und höher CRI (Container Runtime Interface) ein, das den Standard dafür definierte, wie Container-Laufzeitumgebungen mit kubelet interagieren sollen.
Da CRI jedoch nach Docker erschien, verwendet Kubernetes DockerShim als Anpassungsschicht zwischen Docker und CRI, das über HTTP mit der Docker Engine kommuniziert. (Ein Blick in den Quellcode von DockerShim ist eine gute Möglichkeit, das Adapter-Muster zu erklären.)
Schließlich existieren Container in Form von containerd, und die Aufrufkette eliminiert die Anwesenheit der Docker Engine und verwandelt sich in:
K8s Master → kubelet → KubeGenericRuntimeManager → containerd → runC
Im Abstraktionsprozess können wir also mittlere Schichten zur Kompatibilität einführen. Zum Beispiel hat k3s keine harte Abhängigkeit von ETCD als Speicher, aber wenn Funktionen wie Watch benötigt werden, ist eine Wrapper-Schicht für den entsprechenden Speicher erforderlich. Die Wahl hängt von einer Kombination von Faktoren wie Leistung und Komplexität ab.
Dieser Prozess kann natürlich standardisiert werden, und die Implementierung kann von anderen Entwicklern in Open-Source-Form unterstützt werden, um sich an verschiedene Umgebungen anzupassen.
Programmierparadigmen sagen uns, was wir nicht tun sollen
Strukturierte Programmierung sagt uns, wir sollen kein goto
verwenden, sondern stattdessen if-else
verwenden, damit Code später in kleinere Submodule aufgeteilt werden kann.
Strukturierte Programmierung beschränkt und standardisiert die direkte Übertragung der Programmsteuerung, sodass die Steuerung nicht mit goto
-Anweisungen übertragen werden kann.
Objektorientierte Programmierung beschränkt den Missbrauch von Funktionszeigern. Wenn wir eine Struktur wiederverwenden müssen, können wir sie in ein Objekt abstrahieren und das Singleton-Muster verwenden. Wenn wir jedoch nicht möchten, dass sich zwei Entitäten gegenseitig beeinflussen, müssen wir zwei unabhängige Entitäten konstruieren. Obwohl ihre Attribute gleich sein können, sind ihre Werte völlig unterschiedlich und stellen zwei separate Entitäten in der realen Welt dar.
Der Vorteil dieses Ansatzes besteht darin, dass er hilft, Änderungen in Attributen zwischen Entitäten zu isolieren, und es uns ermöglicht, reale Anforderungen besser zu simulieren. Zum Beispiel sind beide Studenten, aber Student A und Student B sind zwei unabhängige Personen. Wenn wir Statistiken über sie sammeln müssen, können wir sie separat bearbeiten.
Funktionale Programmierung beschränkt unsere Zuweisungsverhalten, um zu vermeiden, dass Variablenwerte an Ort und Stelle verändert werden, was anfälliger für Fehler ist.
package main import ( "fmt" ) // Die Map-Funktion nimmt einen Slice von Integern und eine Funktion als Argumente, // und gibt einen neuen Slice zurück, der die Ergebnisse der Anwendung der Funktion auf jedes Element enthält. func MapInts(slice []int, f func(int) int) []int { result := make([]int, len(slice)) for i, v := range slice { result[i] = f(v) } return result } func main() { // Der ursprüngliche Slice von Integern nums := []int{1, 2, 3, 4, 5} // Verwenden Sie funktionale Programmierung, um jedes Element im Slice zu quadrieren squared := MapInts(nums, func(x int) int { return x * x }) // Ergebnisse ausgeben fmt.Println("Original:", nums) fmt.Println("Quadriert:", squared) }
Der Zweck jedes Programmierparadigmas ist es, Einschränkungen festzulegen. Diese Paradigmen sind hauptsächlich dazu gedacht, uns zu sagen, was wir nicht tun sollen, und nicht, was wir tun können.
Programmierpraktiken, die explizit verboten sind, sind die Dinge, die wir nicht tun dürfen. Für alles andere sollten wir uns frei fühlen, zu kombinieren und zu experimentieren, solange es uns hilft, unsere Software fertigzustellen, um das Projekt agiler zu machen.
Nicht stark auf Frameworks verlassen
Zum Beispiel sollten Datenstrukturen im Zusammenhang mit Gin in Golang, wie z. B. gin.Context
, am besten auf die API-Schicht beschränkt und nicht in die Anwendungsschicht übergeben werden.
Abhängigkeiten von Frameworks sollten durch die Erstellung von Proxy-Klassen verwaltet werden.
Bei der Framework-Implementierung geht es eher um die Details und sie gehört zum Firmware-Code. Wenn ein Framework veraltet ist und unser Code seine Abhängigkeiten ändern muss, kann es, wenn wir direkt vom Framework abhängig sind, problematisch sein, da jeder Geschäftsanwender seinen Code ändern muss.
Ein sogenannter Dienst ist lediglich eine etwas kostspieligere Form der Aufteilung des Anwendungsverhaltens im Vergleich zu Funktionsaufrufen, und er steht in keinem Zusammenhang mit der Systemarchitektur.
Wenn beispielsweise eine Methode abstrahiert wurde, ist der Aufruf von Kernel-Code oder RPC nur ein Implementierungsdetail, das sich nur auf die Komplexität der Implementierung und des Debuggens auswirkt. Bei der gesamten Architekturgestaltung sollte dies abstrahiert werden.
Single Responsibility Principle
Ein Modul oder eine Entität sollte nur eine Sache tun – dies ist das Single Responsibility Principle.
Ob es sich um die Gestaltung von Entitäten oder Diensten handelt, Kubernetes hält sich recht gut an diese Regel.
Zum Beispiel ist der kube-scheduler
hauptsächlich dafür verantwortlich, Pods zu Knoten zu planen. Nach der Definition dieser Verantwortung kann der Code Schritt für Schritt aufgeschlüsselt werden: Zuerst werden alle Knoten auf Eignung bewertet, ungeeignete Knoten herausgefiltert und schließlich ein laufender Knoten an den Pod gebunden, der dann von kubelet verwaltet wird, um den Zustand aller Pods auf diesem Knoten aufrechtzuerhalten. An diesem Punkt sind CNI und CRI für die Netzwerk- und Laufzeit-Sandbox-Umgebung von Containern verantwortlich und werden bei Bedarf von Kubelet aufgerufen.
Sie können sehen, dass jede Komponente eine bestimmte Aufgabe zu erledigen hat. Wenn eine Aufgabe komplex ist, wird sie durch das Zusammensetzen anderer Komponenten erledigt.
Anforderungen festlegen, nicht Implementierungsdetails
Beim Ändern des Zustands von Ressourcen sollten wir Kubernetes den gewünschten Zustand mitteilen, nicht wie man ihn erreicht. Dies ist auch der Grund, warum kubelets Rolling-Update veraltet war. Nach der Angabe des gewünschten Zustands kann kubelet selbstständig geeignete Maßnahmen ergreifen, ohne übermäßige externe Eingriffe.
Zum Beispiel überwacht cAdvisor die von Kubernetes bereitgestellten Container. Zuerst müssen wir sehen, welche Metriken überwacht werden können, und diese Metriken dann für Entscheidungen beim automatischen Skalieren verwenden.
Dies ist auch ein Prinzip, dem wir bei der Gestaltung von Komponenten für verschiedene Aufgaben folgen: Klären Sie die zu erfüllenden Anforderungen, konzentrieren Sie sich beim Übertragen von Informationen nur auf Eingaben und Ausgaben und halten Sie die interne Implementierung zusammenhängend. Legen Sie keine Interna nach außen offen; gestalten Sie die externe Verwendung so einfach wie möglich.
Open/Closed Principle
Offen für Erweiterung, geschlossen für Modifikation – dies bedeutet, dass eine Entität ihr Verhalten ändern kann, ohne ihren Quellcode zu ändern.
Die Golang-Syntax ermutigt uns außerdem, vorhandene Entitäten durch Komposition zu erweitern und Schnittstellen für implizite Konvertierungen zu verwenden, wodurch unnötige Details von den Benutzern abgeschirmt werden.
type Kubelet struct{} func (kl *Kubelet) HandlePodAdditions(pods []*Pod) { for _, pod := range pods { fmt.Printf("create pods : %s\n", pod.Status) } } func (kl *Kubelet) Run(updates <-chan Pod) { fmt.Println(" run kubelet") go kl.syncLoop(updates, kl) } func (kl *Kubelet) syncLoop(updates <-chan Pod, handler SyncHandler) { for { select { case pod := <-updates: handler.HandlePodAdditions([]*Pod{&pod}) } } } type SyncHandler interface { HandlePodAdditions(pods []*Pod) }
Wenn wir hier die Funktionalität von kubelet erweitern möchten, müssen wir die ursprüngliche Logik nicht ändern. SyncHandler
kann als Typ dienen, und in der Praxis müssen wir nur SyncHandler
halten. Wenn Änderungen erforderlich sind, können wir direkt Methoden für Kubelet erweitern und dann durch die Schnittstelle für die Benutzer abstrahieren. Der Vorteil dieses Ansatzes besteht darin, dass er die Funktionalität auf Kubelet erweitert, ohne den ursprünglichen Code zu ändern.
Der am häufigsten wiederverwendbare Code ist Geschäftslogik
Geschäftslogik sollte der unabhängigste und am häufigsten wiederverwendbare Code in einem System sein.
Der Kern von Kubernetes liegt in der Verwaltung von Container-Orchestrierungszuständen. Was die Art des Speichers zur Speicherung des Zustands von Containern betrifft – ob ETCD oder eine relationale Datenbank – ist eigentlich nicht der wichtigste Teil und kann flexibel ersetzt werden. Ebenso können wir für die Einrichtung des Netzwerks mit CNI je nach realen Szenarien wählen. Die Orchestrierung und Verwaltung von Containern ist jedoch, sobald sie am Anfang festgelegt wurde, im Allgemeinen der stabilste Teil.
Zuerst zum Laufen bringen, dann verbessern
- „Den Code zuerst zum Laufen bringen“ – Wenn der Code nicht funktioniert, kann er keinen Wert schaffen. In den frühen Versionen von Kubernetes gab es also viele starke Abhängigkeiten, z. B. vom Flannel-Netzwerk-Plugin oder von Docker als Container-Laufzeitumgebung.
- „Dann versuchen, ihn zu verbessern“ – Durch Refactoring ermöglichen wir uns und anderen, den Code besser zu verstehen und ihn bei sich ändernden Anforderungen kontinuierlich zu ändern. In späteren Phasen entfernte Kubernetes diese starken Abhängigkeiten, indem es Spezifikationen wie CNI und CRI erstellte, die es verschiedenen Anbietern ermöglichten, sich basierend auf ihren tatsächlichen physischen Umgebungen zu entwickeln. Dies ist Teil der Bemühungen, ihn zu verbessern.
- „Schließlich versuchen, ihn schneller laufen zu lassen“ – Optimieren Sie den Code basierend auf Leistungsverbesserungsanforderungen. Dies ist die Reifephase der Software. Am Anfang, bei der Implementierung der Funktionalität, gibt es viele Details, die nicht fein genug behandelt werden, und selbst das Überwachungssystem ist möglicherweise nicht ausgereift genug, um uns zu helfen, Probleme zu finden. Sobald die Funktionalität abgeschlossen ist, können wir lokal optimieren, bestimmte Algorithmen ersetzen und die Gesamtleistung verbessern.
Wir sind Leapcell, Ihre erste Wahl für das Hosting von Go-Projekten.
Leapcell ist die Serverless-Plattform der nächsten Generation für Webhosting, asynchrone Aufgaben und Redis:
Unterstützung mehrerer Sprachen
- Entwickeln Sie mit Node.js, Python, Go oder Rust.
Stellen Sie unbegrenzt viele Projekte kostenlos bereit
- zahlen Sie nur für die Nutzung – keine Anfragen, keine Gebühren.
Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: 25 $ unterstützen 6,94 Millionen Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
Optimierte Entwicklererfahrung
- Intuitive Benutzeroberfläche für mühelose Einrichtung.
- Vollautomatische CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und -protokollierung für umsetzbare Erkenntnisse.
Mühelose Skalierbarkeit und hohe Leistung
- Automatisches Skalieren zur einfachen Bewältigung hoher Parallelität.
- Kein betrieblicher Overhead – konzentrieren Sie sich einfach auf den Aufbau.
Erfahren Sie mehr in der Dokumentation!
Folgen Sie uns auf X: @LeapcellHQ