Go Engineering Praktiken von Kubernetes lernen
Olivia Novak
Dev Intern · Leapcell

Map Lesen und Schreiben
In Kubernetes sehen wir oft, dass viele Änderungen durch Schreiben in einen Kanal vor der Ausführung ausgeführt werden. Dieser Ansatz stellt sicher, dass Single-Thread-Routinen Nebenläufigkeitsprobleme vermeiden, und er entkoppelt auch Produktion und Konsum.
Wenn wir jedoch einfach eine Map durch Sperren ändern, ist die Leistung der Verwendung von Kanälen nicht so gut wie das direkte Sperren. Betrachten wir den folgenden Code für einen Leistungstest.
writeToMapWithMutex
bearbeitet die Map durch Sperren, während writeToMapWithChannel
in einen Kanal schreibt, der dann von einer anderen Goroutine konsumiert wird.
package map_modify import ( "sync" ) const mapSize = 1000 const numIterations = 100000 func writeToMapWithMutex() { m := make(map[int]int) var mutex sync.Mutex for i := 0; i < numIterations; i++ { mutex.Lock() m[i%mapSize] = i mutex.Unlock() } } func writeToMapWithChannel() { m := make(map[int]int) ch := make(chan struct { key int value int }, 256) var wg sync.WaitGroup go func() { wg.Add(1) for { entry, ok := <-ch if !ok { wg.Done() return } m[entry.key] = entry.value } }() for i := 0; i < numIterations; i++ { ch <- struct { key int value int }{i % mapSize, i} } close(ch) wg.Wait() }
Benchmark-Test:
go test -bench . goos: windows goarch: amd64 pkg: golib/examples/map_modify cpu: Intel(R) Core(TM) i7-9700 CPU @ 3.00GHz BenchmarkMutex-8 532 2166059 ns/op BenchmarkChannel-8 186 6409804 ns/op
Wir können sehen, dass das direkte Sperren zur Änderung der Map effizienter ist. Wenn die Änderung nicht komplex ist, bevorzugen wir daher die direkte Verwendung von sync.Mutex
, um Probleme mit gleichzeitigen Änderungen zu vermeiden.
Immer auf Nebenläufigkeit auslegen
K8s verwendet ausgiebig Kanäle, um Signale zu übermitteln, sodass die eigene Logikverarbeitung nicht durch unvollendete Arbeit von Upstream- oder Downstream-Komponenten blockiert wird. Dies verbessert nicht nur die Effizienz der Aufgabenausführung, sondern ermöglicht auch minimale Wiederholungsversuche, wenn Fehler auftreten, und die Idempotenz kann in kleine Module zerlegt werden.
Die Behandlung von Ereignissen wie dem Löschen, Hinzufügen und Aktualisieren von Pods kann alles gleichzeitig erfolgen. Es ist nicht erforderlich, auf die Beendigung eines Vorgangs zu warten, bevor der nächste verarbeitet wird. Wenn also ein Pod hinzugefügt wird, können Sie mehrere Listener für die Verteilung registrieren. Solange Sie das Ereignis in den Kanal schreiben, gilt es als erfolgreich ausgeführt, und die Zuverlässigkeit nachfolgender Aktionen wird durch den Executor sichergestellt. Auf diese Weise wird das aktuelle Ereignis nicht an der gleichzeitigen Ausführung gehindert.
type listener struct { eventObjs chan eventObj } // watch // // @Description: Hören Sie auf den Inhalt, der verarbeitet werden muss func (l *listener) watch() chan eventObj { return l.eventObjs } // Event-Objekt, Sie können den zu übergebenden Inhalt nach Belieben definieren type eventObj struct{} var ( listeners = make([]*listener, 0) ) func distribute(obj eventObj) { for _, l := range listeners { // Verteilen Sie das Event-Objekt direkt hier l.eventObjs <- obj } }
DeltaFIFOs Deduplizierung von Löschaktionen
func dedupDeltas(deltas Deltas) Deltas { n := len(deltas) if n < 2 { return deltas } a := &deltas[n-1] b := &deltas[n-2] if out := isDup(a, b); out != nil { deltas[n-2] = *out return deltas[:n-1] } return deltas } func isDup(a, b *Delta) *Delta { // Wenn beides Löschoperationen sind, führen Sie eine davon zusammen if out := isDeletionDup(a, b); out != nil { return out } return nil }
Hier können wir sehen, dass wir der Warteschlange individuell Deduplizierungslogik hinzufügen können, da Ereignisse von einer separaten Warteschlange verwaltet werden.
Da die Komponente innerhalb des Pakets gekapselt ist, sehen externe Benutzer die interne Komplexität nicht – sie müssen nur nachfolgende Ereignisse weiterverarbeiten. Das Entfernen eines Löschereignisses beeinträchtigt nicht die Gesamtlogik.
Orthogonales Design zwischen Komponenten
Was ist orthogonales Design? Es bedeutet, dass das, was jede Komponente tut, unabhängig von den anderen ist und sie ohne gegenseitige Abhängigkeit frei zusammengesetzt werden können. Beispielsweise ist kube-scheduler nur dafür verantwortlich, Pods bestimmte Knoten zuzuweisen, und nach der Zuweisung übergibt er das Ergebnis nicht direkt an kubelet zur Ausführung. Stattdessen speichert er die Zuweisung über den Api-Server in etcd. Auf diese Weise ist er nur vom Api-Server für die Aufgabenübermittlung abhängig.
Kubelet überwacht auch direkt die vom Api-Server übermittelten Aufgaben. Daher kann er nicht nur die von kube-scheduler übermittelten Aufgaben verwalten, sondern auch Anfragen vom Api-Server zum Löschen von Pods bearbeiten. Somit werden die Dinge, die sie unabhängig voneinander tun können, multipliziert, um die Gesamtzahl der Dinge zu ergeben, die sie zusammen erreichen können.
Implementierung von Timern
Um Aufgaben regelmäßig mit Crontab auszulösen, können Sie zuerst eine Schnittstelle schreiben, um die Logik nach dem Auslösen der Aufgabe zu verarbeiten, und dann das Curl-Image verwenden, um die Aufgabe nach einem Zeitplan zu starten.
apiVersion: batch/v1beta1 kind: CronJob metadata: name: task spec: schedule: '0 10 * * *' jobTemplate: spec: template: spec: containers: - name: task-curl image: curlimages/curl resources: limits: cpu: '200m' memory: '512Mi' requests: cpu: '100m' memory: '256Mi' args: - /bin/sh - -c - | echo "Starting create task of CronJob" resp=$(curl -H "Content-Type: application/json" -v -i -d '{"params": 1000}' <http://service-name>:port/api/test) echo "$resp" exit 0 restartPolicy: Never successfulJobsHistoryLimit: 2 failedJobsHistoryLimit: 3
Abstrahieren von Firmware-Code
Kubernetes verfolgt diesen Ansatz auch bei der Gestaltung von CNI (Container Network Interface), für das k8s eine Reihe von Regeln für Netzwerk-Plugins aufgestellt hat. Der Zweck von CNI ist es, die Netzwerkkonfiguration von Containerplattformen zu entkoppeln, sodass Sie auf verschiedenen Plattformen nur verschiedene Netzwerk-Plugins verwenden müssen und andere containerisierte Inhalte weiterhin wiederverwendet werden können. Sie müssen nur wissen, dass der Container erstellt wurde, und der Rest der Vernetzung wird vom CNI-Plugin übernommen. Sie müssen dem CNI-Plugin lediglich die in der Spezifikation vereinbarte Konfiguration zur Verfügung stellen.
Können wir steckbare Komponenten wie CNI in der Geschäftsimplementierung entwerfen?
Natürlich können wir das. In der Geschäftsentwicklung ist die am häufigsten verwendete Datenbank ein Tool, das indirekt von der Geschäftslogik verwendet wird. Die Geschäftslogik muss nichts über die Tabellenstruktur, die Abfragesprache oder andere interne Details der Datenbank wissen. Das Einzige, was die Geschäftslogik wissen muss, ist, dass Funktionen zum Abfragen und Speichern von Daten verfügbar sind. Auf diese Weise kann die Datenbank hinter einer Schnittstelle verborgen werden.
Wenn wir eine andere zugrunde liegende Datenbank benötigen, müssen wir nur die Datenbankinitialisierung auf Codeebene umschalten. Gorm hat den größten Teil der Treiberlogik für uns abstrahiert. Während der Initialisierung wird durch das Übergeben eines anderen DSN ein anderer Treiber aktiviert, der dann die von uns auszuführenden Anweisungen übersetzt.
Eine gute Architektur sollte um Anwendungsfälle herum organisiert sein, damit sie den Anwendungsfall vollständig beschreiben kann, unabhängig vom Framework, den Tools oder der Laufzeitumgebung.
Dies ist ähnlich dem primären Ziel der Wohngebäudeplanung, das darin bestehen sollte, die Bedürfnisse des Wohnens zu erfüllen und nicht darauf zu bestehen, das Haus mit Ziegeln zu bauen. Ein Architekt sollte erhebliche Anstrengungen unternehmen, um sicherzustellen, dass die Architektur den Benutzern so viel Freiheit wie möglich bei der Auswahl der Baumaterialien lässt und gleichzeitig ihre Bedürfnisse erfüllt.
Aufgrund der Abstraktionsschicht zwischen Gorm und der eigentlichen Datenbank müssen Sie sich bei der Implementierung von Benutzerregistrierung und -anmeldung nicht darum kümmern, ob die zugrunde liegende Datenbank MySQL oder Postgres ist. Sie beschreiben lediglich, dass nach der Benutzerregistrierung Benutzerinformationen gespeichert werden und für die Anmeldung das entsprechende Passwort überprüft werden muss. Basierend auf den Anforderungen an Systemzuverlässigkeit und -leistung können Sie dann flexibel die bei der Implementierung zu verwendenden Komponenten auswählen.
Vermeiden Sie Overengineering
Overengineering ist oft schlimmer als unzureichendes Engineering-Design.
Die früheste Version von Kubernetes war 0.4. In seinem Netzwerkbereich bestand die offizielle Implementierung zu diesem Zeitpunkt darin, GCE zu verwenden, um Salt-Skripte zum Erstellen von Bridges auszuführen, und für andere Umgebungen wurden Flannel und OVS als empfohlene Lösungen empfohlen.
Im Laufe der Entwicklung von Kubernetes erwies sich Flannel in einigen Fällen als unzureichend. Um 2015 entstanden in der Community Calico und Weave, die die Netzwerkprobleme im Wesentlichen lösten, sodass Kubernetes keine eigenen Anstrengungen mehr dafür aufwenden musste. Daher wurde CNI eingeführt, um Netzwerk-Plugins zu standardisieren.
Wie wir sehen können, war Kubernetes von Anfang an nicht perfekt konzipiert. Stattdessen wurden im Laufe der Zeit, als weitere Probleme auftraten, kontinuierlich neue Designs eingeführt, um sich an die sich ändernden Umgebungen anzupassen.
Scheduler Framework
In kube-scheduler bietet das Framework Mount-Punkte, sodass später Plugins hinzugefügt werden können. Wenn Sie beispielsweise ein Node-Scoring-Plugin hinzufügen möchten, müssen Sie nur die Schnittstelle ScorePlugin
implementieren und das Plugin über die Registry im Array scorePlugins
des Frameworks registrieren. Schließlich wird das vom Scheduler zurückgegebene Ergebnis in Status
verpackt, das den Fehler, den Code und den Namen des Plugins enthält, das den Fehler verursacht hat.
Wenn die Einfügepunkte des Frameworks nicht festgelegt sind, ist die Ausführungslogik relativ verstreut. Wenn Sie Logik hinzufügen, da es keinen einheitlichen Mount-Punkt gibt, fügen Sie am Ende möglicherweise überall Logik hinzu.
Mit der Abstraktion des Frameworks müssen Sie nur wissen, in welcher Phase Sie Logik hinzufügen möchten. Nachdem Sie Ihren Code geschrieben haben, registrieren Sie ihn einfach. Dies erleichtert auch das Testen einzelner Komponenten, standardisiert die Entwicklung jeder Komponente, und beim Lesen von Quellcode müssen Sie nur den Teil überprüfen, den Sie ändern oder verstehen möchten.
Hier ist ein vereinfachtes Codebeispiel:
type Framework struct { sync.Mutex scorePlugins []ScorePlugin } func (f *Framework) RegisterScorePlugin(plugin ScorePlugin) { f.Lock() defer f.Unlock() f.scorePlugins = append(f.scorePlugins, plugin) } func (f *Framework) runScorePlugins(node string, pod string) int { var score int for _, plugin := range f.scorePlugins { score += plugin.Score(node, pod) // Hier können Sie mit einem Gewicht multiplizieren, wenn Plugins unterschiedliche Gewichtungen haben } return score }
Dieser zentralisierte Ansatz erleichtert auch das Hinzufügen einer einheitlichen Verarbeitungslogik für ähnliche Komponenten. Beispielsweise können Scoring-Plugins gleichzeitig Scores für mehrere Knoten berechnen, ohne warten zu müssen, bis jeder Knoten nacheinander fertig ist.
type Parallelizer struct { Concurrency int ch chan struct{} } func NewParallelizer(concurrency int) *Parallelizer { return &Parallelizer{ Concurrency: concurrency, ch: make(chan struct{}, concurrency), } } type DoWorkerPieceFunc func(piece int) func (p *Parallelizer) Until(pices int, f DoWorkerPieceFunc) { wg := sync.WaitGroup{} for i := 0; i < pices; i++ { p.ch <- struct{}{} wg.Add(1) go func(i int) { defer func() { <-p.ch wg.Done() }() f(i) }(i) } wg.Wait() }
Sie können eine Closure verwenden, um die Informationen der Berechnungskomponente zu übergeben, und dann den Parallelizer diese gleichzeitig ausführen lassen.
func (f *Framework) RunScorePlugins(nodes []string, pod *Pod) map[string]int { scores := make(map[string]int) p := concurrency.NewParallelizer(16) p.Until(len(nodes), func(i int) { scores[nodes[i]] = f.runScorePlugins(nodes[i], pod.Name) }) // Node-Bindungslogik ausgelassen return scores }
Dieses Programmierparadigma kann sehr gut in Geschäftsszenarien angewendet werden. Beispielsweise müssen Sie nach dem Abruf in Empfehlungsergebnissen häufig verschiedene Strategien filtern und sortieren.
Wenn es Aktualisierungen in der Strategieorchestrierung gibt, benötigen Sie Hot-Reloading, und die internen Logikdaten von Filtern können sich ebenfalls ändern, z. B. Änderungen in Blacklists, Änderungen in gekauften Benutzerdaten oder Änderungen im Produktstatus. Zu diesem Zeitpunkt sollten laufende Aufgaben weiterhin die alte Filterlogik verwenden, neue Aufgaben jedoch die neuen Regeln.
type Item struct{} type Filter interface { DoFilter(items []Item) []Item } // ConstructorFilters // // @Description: Jedes Mal wird ein neuer Filter erstellt, und wenn sich der Cache ändert, wird er aktualisiert. Neue Aufgaben verwenden die neue Filterkette. // @return []Filter func ConstructorFilters() []Filter { // Die Filterstrategien können hier aus der Konfigurationsdatei gelesen und dann initialisiert werden return []Filter{ &BlackFilter{}, // Wenn sich die interne Logik ändert, kann sie über den Konstruktor aktualisiert werden &AlreadyBuyFilter{}, } } func RunFilters(items []Item, fs []Filter) []Item { for _, f := range fs { items = f.DoFilter(items) } return items }
Das Aufteilen von Diensten ist nicht gleichbedeutend mit Architekturdesign
Das Aufteilen von Diensten ändert die Kopplung zwischen Diensten tatsächlich nur von der Kopplung auf Codeebene zur Kopplung auf Datenebene. Wenn beispielsweise ein Downstream-Dienst ein geändertes Feld benötigt, muss die Upstream-Pipeline dieses Feld ebenfalls verarbeiten. Dies ist nur eine lokalisierte Isolierung. Wenn Sie Dienste jedoch nicht aufteilen, können Sie durch die Schichtung Ihres Codes dennoch eine ähnliche Isolierung erreichen – indem Sie Funktionsein- und -ausgaben verwenden, um ähnliche Effekte wie bei der Dienstaufteilung zu erzielen.
Die Dienstaufteilung ist nur eine Möglichkeit, ein Systemprogramm aufzuteilen, und die Dienstgrenze ist nicht die Systemgrenze. Bei der Dienstgrenze geht es eher um Komponentengrenzen. Ein Dienst kann mehrere Arten von Komponenten enthalten.
Zum Beispiel der k8s-Api-Server.
Oder in einem Empfehlungssystem können sich sowohl Empfehlungsaufgaben als auch Empfehlungslisten in einer Komponente befinden. Empfehlungsaufgaben können viele Arten sein: Produkte an eine Personengruppe pushen, einen Stapel von Produkten an bestimmte Benutzer pushen, Anzeigenbereitstellung usw. Dies sind verschiedene Arten von Empfehlungsaufgaben, die jedoch innerhalb einer Komponente abstrahiert werden. Nachgeschaltete Benutzer dieser Daten wissen nicht, welche Regeln intern verwendet wurden, um sie zu generieren – sie nehmen lediglich wahr, dass einem Benutzer ein bestimmtes Produkt empfohlen wurde. Dies ist die Abstraktion von Upstream- und Downstream-Grenzen. Änderungen an der Logik innerhalb der Empfehlungsaufgabenkomponente wirken sich nicht auf die von nachgeschalteten Diensten verbrauchten Daten aus, die immer (Benutzer, Artikel)-Paare sehen. Die Empfehlungsdienstlogik ist also eine Komponente, die nach der unabhängigen Bereitstellung sowohl von Upstream- als auch von Downstream-Diensten verwendet werden kann.
Main-Funktion Startup
Sie können Cobra verwenden, um einen strukturierten Befehl zu erstellen:
kubelet --help
Mit diesem Befehl können Sie die optionalen Parameter für das CLI-Tool anzeigen.
Wenn Ihre Anwendung ein Webserver ist, können Sie das Startverhalten ändern, z. B. auf welchem Port gelauscht werden soll oder welche Konfigurationsdatei verwendet werden soll, indem Sie Parameter übergeben.
Wenn Ihr Programm ein CLI-Tool ist, können Sie Parameter flexibler verfügbar machen, sodass Benutzer selbst über das Verhalten des Befehls entscheiden können.
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:
Multi-Sprachen-Unterstützung
- Entwickeln Sie mit Node.js, Python, Go oder Rust.
Stellen Sie unbegrenzt Projekte kostenlos bereit
- Zahlen Sie nur für die Nutzung – keine Anfragen, keine Kosten.
Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufkosten.
- Beispiel: 25 US-Dollar unterstützen 6,94 Millionen Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
Optimierte Entwicklungserfahrung
- Intuitive Benutzeroberfläche für müheloses Setup.
- Vollständig automatisierte CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und Protokollierung für verwertbare Erkenntnisse.
Mühelose Skalierbarkeit und hohe Leistung
- Automatische Skalierung zur einfachen Bewältigung hoher Parallelität.
- Kein Betriebsaufwand – konzentrieren Sie sich einfach auf das Bauen.
Erfahren Sie mehr in der Dokumentation!
Folgen Sie uns auf X: @LeapcellHQ