Kubernetes Operator-Entwicklung mit Operator Framework

Bild


Wie im Artikel über Radartechnologie erwähnt, bewegt sich Lamoda aktiv in Richtung Microservice-Architektur. Die meisten unserer Dienste werden mit Helm verpackt und auf Kubernetes bereitgestellt. Dieser Ansatz entspricht in 99% der Fälle voll und ganz unseren Anforderungen. 1% verbleibt, wenn die Standardfunktionalität von Kubernetes nicht ausreicht, z. B. wenn Sie eine Sicherung konfigurieren oder einen Dienst für ein bestimmtes Ereignis aktualisieren müssen. Um dieses Problem zu lösen, verwenden wir das Operatormuster. In dieser Artikelserie werde ich - Grigory Mikhalkin, der Entwickler des Forschungs- und Entwicklungsteams bei Lamoda - über die Lehren sprechen, die ich aus meinen Erfahrungen bei der Entwicklung von K8-Operatoren mit dem Operator Framework gezogen habe .


Was ist ein Operator?


Eine Möglichkeit, die Kubernetes-Funktionalität zu erweitern, besteht darin, eigene Controller zu erstellen. Die Hauptabstraktionen in Kubernetes sind Objekte und Controller. Objekte beschreiben den gewünschten Status des Clusters. Ein Pod beschreibt beispielsweise, welche Container gestartet und welche Parameter gestartet werden müssen, und das ReplicaSet- Objekt gibt an, wie viele Replikate eines bestimmten Pod gestartet werden müssen. Die Controller steuern den Status des Clusters basierend auf der Beschreibung der Objekte. In dem oben beschriebenen Fall unterstützt der ReplicationController die Anzahl der im ReplicaSet angegebenen Pod-Replikate. Mithilfe neuer Controller können Sie zusätzliche Logik implementieren, z. B. das Senden von Benachrichtigungen für Ereignisse, die Wiederherstellung nach einem Fehler oder die Verwaltung von Ressourcen Dritter .


Ein Operator ist eine Kubernetes-Anwendung, die einen oder mehrere Controller enthält, die eine Ressource eines Drittanbieters bedienen. Das Konzept wurde 2016 vom CoreOS-Team erfunden. In letzter Zeit hat die Beliebtheit von Betreibern rapide zugenommen. Sie können versuchen, den gewünschten Operator in der Liste auf kubedex (hier sind mehr als 100 öffentlich verfügbare Operatoren aufgeführt) sowie auf OperatorHub zu finden . Es gibt 3 beliebte Tools für die Operatorentwicklung: Kubebuilder , Operator SDK und Metacontroller . In Lamoda verwenden wir das Operator SDK, daher werden wir später darüber sprechen.


Operator SDK


Bild


Das Operator SDK ist Teil des Operator Frameworks, das zwei weitere wichtige Teile enthält: Operator Lifecycle Manager und Operator Metering.


  • Das Operator SDK ist ein Wrapper für die Controller-Laufzeit , eine beliebte Bibliothek für die Entwicklung von Controllern (die wiederum ein Wrapper für Client-Go ist ), ein Codegenerator + Framework zum Schreiben von E2E-Tests.
  • Operator Lifecycle Manager - ein Framework für die Verwaltung vorhandener Operatoren; Behebt Situationen, in denen der Bediener in den Zombie-Modus wechselt oder eine neue Version eingeführt wird.
  • Bedienermessung - Wie der Name schon sagt, werden Daten zur Arbeit des Bedieners erfasst und basierend darauf können auch Berichte erstellt werden.

Erstellen Sie ein neues Projekt


Ein Beispiel ist ein Operator, der eine Datei mit Konfigurationen im Repository überwacht und bei Aktualisierung die Bereitstellung des Dienstes mit neuen Konfigurationen neu startet. Den vollständigen Beispielcode finden Sie hier .


Bild


Erstellen Sie ein Projekt mit einem neuen Operator:


operator-sdk new config-monitor 

Der Codegenerator erstellt Code für den Operator, der im zugewiesenen Namespace arbeitet . Dieser Ansatz ist dem Zugriff auf den gesamten Cluster vorzuziehen, da im Fehlerfall die Probleme innerhalb desselben Namespace isoliert werden. Der cluster-wide Operator kann durch Hinzufügen von --cluster-scoped generiert werden. Die folgenden Verzeichnisse befinden sich im erstellten Projekt:


  • cmd - enthält das main package , in dem Manager initialisiert und gestartet wird;
  • deploy - enthält Deklarationen des Operators, der CRD und der Objekte, die zum Einrichten des RBAC-Operators erforderlich sind.
  • pkg - hier ist unser Hauptcode für neue Objekte und Controller.

Es gibt nur eine cmd/manager/main.go Datei in cmd/manager/main.go .


Code-Snippet
 // Become the leader before proceeding err = leader.Become(ctx, "config-monitor-lock") if err != nil { log.Error(err, "") os.Exit(1) } // Create a new Cmd to provide shared dependencies and start components mgr, err := manager.New(cfg, manager.Options{ Namespace: namespace, MetricsBindAddress: fmt.Sprintf("%s:%d", metricsHost, metricsPort), }) ... // Setup Scheme for all resources if err := apis.AddToScheme(mgr.GetScheme()); err != nil { log.Error(err, "") os.Exit(1) } // Setup all Controllers if err := controller.AddToManager(mgr); err != nil { log.Error(err, "") os.Exit(1) } ... // Start the Cmd if err := mgr.Start(signals.SetupSignalHandler()); err != nil { log.Error(err, "Manager exited non-zero") os.Exit(1) } 

In der ersten Zeile: err = leader.Become(ctx, "config-monitor-lock") - ein Leader wird ausgewählt. In den meisten Szenarien wird nur eine aktive Instanz einer Anweisung für Namespace / Cluster benötigt. Standardmäßig verwendet das Operator SDK die Leader for Life- Strategie. Die erste gestartete Instanz des Operators bleibt der Leader, bis sie aus dem Cluster entfernt wird.


Nachdem diese Operatorinstanz zum Leader ernannt wurde, wird ein neuer Manager initialisiert - mgr, err := manager.New(...) . Zu seinen Aufgaben gehören:


  • err := apis.AddToScheme(mgr.GetScheme()) - Registrierung neuer Ressourcenschemata;
  • err := controller.AddToManager(mgr) - Registrierung von Controllern;
  • err := mgr.Start(signals.SetupSignalHandler()) - Starten und Steuern der Controller.

Im Moment haben wir weder neue Ressourcen noch Controller für die Registrierung. Sie können eine neue Ressource mit dem folgenden Befehl hinzufügen:


 operator-sdk add api --api-version=services.example.com/v1alpha1 --kind=MonitoredService 

Dieser Befehl fügt die Definition des MonitoredService Ressourcenschemas zum Verzeichnis pkg/apis sowie yaml mit der CRD Definition in deploy/crds . Von allen generierten Dateien sollten Sie nur die Schemadefinition in monitoredservice_types.go manuell ändern. Der Typ MonitoredServiceSpec definiert den gewünschten Status der Ressource: Was der Benutzer in yaml mit der Definition der Ressource angibt. Im Kontext unseres Operators bestimmt das Feld Size die gewünschte Anzahl von Replikaten. ConfigRepo gibt an, woher die aktuellen Konfigurationen abgerufen werden können. MonitoredServiceStatus ermittelt den beobachteten Status der Ressource. Beispielsweise werden die Namen der zu dieser Ressource gehörenden Pods und die aktuellen spec Pods gespeichert.


Nach dem Bearbeiten des Schemas müssen Sie den folgenden Befehl ausführen:


 operator-sdk generate k8s 

Die CRD Definition in deploy/crds wird aktualisiert.


Erstellen wir nun den Hauptteil unseres Operators, den Controller:


 operator-sdk add controller --api-version=services.example.com/v1alpha1 --kind=Monitor 

Die Datei monitor_controller.go wird im monitor_controller.go pkg/controller monitor_controller.go , in das wir die benötigte Logik monitor_controller.go .


Controller-Entwicklung


Die Steuerung ist die Hauptarbeitseinheit des Bedieners. In unserem Fall gibt es zwei Controller:


  • Monitor Controller überwacht Änderungen der Servicekonfiguration.
  • Der Upgrade-Controller aktualisiert den Dienst und hält ihn im gewünschten Zustand.

Im Kern ist der Controller ein Regelkreis, er überwacht die Warteschlange mit den Ereignissen, die er abonniert hat, und verarbeitet sie:


Bild


Ein neuer Controller wird vom Manager in der add Methode erstellt und registriert:


 c, err := controller.New("monitor-controller", mgr, controller.Options{Reconciler: r}) 

Mit der Watch Methode abonnieren wir Ereignisse bezüglich der Erstellung einer neuen Ressource oder der Spec einer vorhandenen MonitoredService Ressource:


 err = c.Watch(&source.Kind{Type: &servicesv1alpha1.MonitoredService{}}, &handler.EnqueueRequestForObject{}, common.CreateOrUpdateSpecPredicate) 

Der Ereignistyp kann mithilfe der Parameter src und predicates konfiguriert werden. src akzeptiert Objekte vom Typ Source .


  • Informer - fragt den apiserver nach Ereignissen ab, die mit dem Filter übereinstimmen. Wenn ein solches Ereignis apiserver , wird es in die Warteschlange des Controllers gestellt. In der controller-runtime dies ein Wrapper über den SharedIndexInformer von client-go .
  • Kind ist auch ein Wrapper über SharedIndexInformer , erstellt jedoch im Gegensatz zu Informer unabhängig eine Informer-Instanz basierend auf den übergebenen Parametern (Schema der überwachten Ressource).
  • Channel - akzeptiert chan event.GenericEvent als Parameter, die durch ihn kommenden Ereignisse werden in die Warteschlange des Controllers gestellt.

redicates erwarten Objekte, die die Predicate erfüllen. Tatsächlich ist dies ein zusätzlicher Filter für Ereignisse. UpdateEvent Sie beispielsweise UpdateEvent filtern UpdateEvent können Sie genau sehen, welche Änderungen in der Ressourcenspezifikation vorgenommen wurden.


Wenn ein Ereignis eintrifft, akzeptiert es ein EventHandler - das zweite Argument der Watch Methode -, das das Ereignis in das vom Reconciler erwartete Anforderungsformat EventHandler :


  • EnqueueRequestForObject - EnqueueRequestForObject eine Anforderung mit dem Namen und dem Namespace des Objekts, das das Ereignis verursacht hat.
  • EnqueueRequestForOwner - EnqueueRequestForOwner eine Anforderung mit den Daten des übergeordneten Objekts. Dies ist beispielsweise erforderlich, wenn der ressourcengesteuerte Pod gelöscht wurde und Sie mit dem Ersetzen beginnen müssen.
  • EnqueueRequestsFromMapFunc - verwendet als Parameter die map Funktion, die ein Ereignis empfängt (in MapObject ) und eine Liste von Anforderungen zurückgibt. Ein Beispiel, wenn dieser Handler benötigt wird - es gibt einen Timer, für den Sie für jeden Tick neue Konfigurationen für alle verfügbaren Dienste abrufen müssen.

Anforderungen werden in die Controller-Warteschlange gestellt, und einer der Worker (standardmäßig hat der Controller eine) zieht das Ereignis aus der Warteschlange und übergibt es an Reconciler .


Reconciler implementiert nur eine Methode - Reconcile , die die grundlegende Logik der Ereignisverarbeitung enthält:


Methode abgleichen
 func (r *ReconcileMonitor) Reconcile(request reconcile.Request) (reconcile.Result, error) { reqLogger := log.WithValues("Request.Namespace", request.Namespace, "Request.Name", request.Name) reqLogger.Info("Checking updates in repo for MonitoredService") // fetch the Monitor instance instance := &servicesv1alpha1.MonitoredService{} err := r.client.Get(context.Background(), request.NamespacedName, instance) if err != nil { if errors.IsNotFound(err) { // Request object not found, could have been deleted after reconcile request. // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers. // Return and don't requeue return reconcile.Result{}, nil } // Error reading the object - requeue the request. return reconcile.Result{}, err } // check if service's config was updated // if it was, send event to upgrade controller if podSpec, ok := r.isServiceConfigUpdated(instance); ok { // Update instance Spec instance.Status.PodSpec = *podSpec instance.Status.ConfigChanged = true err = r.client.Status().Update(context.Background(), instance) if err != nil { reqLogger.Error(err, "Failed to update service status", "Service.Namespace", instance.Namespace, "Service.Name", instance.Name) return reconcile.Result{}, err } r.eventsChan <- event.GenericEvent{Meta: &servicesv1alpha1.MonitoredService{}, Object: instance} } return reconcile.Result{}, nil } 

Die Methode akzeptiert ein Request mit dem Feld NamespacedName , mit dem die Ressource aus dem Cache r.client.Get(context.TODO(), request.NamespacedName, instance) kann: r.client.Get(context.TODO(), request.NamespacedName, instance) . In diesem Beispiel wird eine Anforderung an die Datei mit der Dienstkonfiguration gestellt, auf die ConfigRepo Feld ConfigRepo in der Ressourcenspezifikation verwiesen wird. Wenn die Konfiguration aktualisiert wird, wird ein neues Ereignis vom Typ GenericEvent und an den Kanal gesendet, den der Upgrade Controller abhört.


Nach der Verarbeitung der Anforderung gibt Reconcile ein Objekt vom Typ Result und error . Wenn das Result Requeue: true oder error != nil , gibt der Controller die Anforderung mithilfe der Methode queue.AddRateLimited an die Warteschlange queue.AddRateLimited . Die Anforderung wird mit einer Verzögerung an die Warteschlange zurückgesendet, die von RateLimiter festgelegt wird. Standardmäßig wird ItemExponentialFailureRateLimiter verwendet, wodurch die Verzögerungszeit exponentiell mit einer Erhöhung der Anzahl der "Rückgaben" von Anforderungen erhöht wird. Wenn das Feld " Requeue nicht festgelegt ist und während der Verarbeitung der Anforderung kein Fehler aufgetreten ist, ruft der Controller die Queue.Forget Methode auf, mit der die Anforderung aus dem Cache des RateLimiter (wodurch die Anzahl der Rückgaben zurückgesetzt wird). Am Ende der Anforderungsverarbeitung entfernt der Controller sie mithilfe der Queue.Done Methode aus der Warteschlange.


Bedienerstart


Die Komponenten des Bedieners wurden oben beschrieben, und eine Frage blieb offen: wie man es startet. Zuerst müssen Sie sicherstellen, dass alle erforderlichen Ressourcen installiert sind (für lokale Tests empfehle ich die Einrichtung von minikube ):


 # Setup Service Account kubectl create -f deploy/service_account.yaml # Setup RBAC kubectl create -f deploy/role.yaml kubectl create -f deploy/role_binding.yaml # Setup the CRD kubectl create -f deploy/crds/services_v1alpha1_monitoredservice_crd.yaml # Setup custom resource kubectl create -f deploy/crds/services_v1alpha1_monitoredservice_cr.yaml 

Sobald die Voraussetzungen erfüllt sind, gibt es zwei einfache Möglichkeiten, die Anweisung zum Testen auszuführen. Am einfachsten ist es, es außerhalb des Clusters mit dem folgenden Befehl zu starten:


 operator-sdk up local --namespace=default 

Die zweite Möglichkeit besteht darin, den Operator im Cluster bereitzustellen. Zuerst müssen Sie mit dem Operator ein Docker-Image erstellen:


 operator-sdk build config-monitor-operator:latest 

Ersetzen REPLACE_IMAGE in der Datei deploy/operator.yaml REPLACE_IMAGE durch config-monitor-operator:latest :


 sed -i "" 's|REPLACE_IMAGE|config-monitor-operator:latest|g' deploy/operator.yaml 

Bereitstellung mit Anweisung erstellen:


 kubectl create -f deploy/operator.yaml 

Jetzt sollte in der Liste der Pod im Cluster Pod mit einem Testdienst angezeigt werden, und im zweiten Fall ein weiterer mit einem Operator.


Anstelle einer Schlussfolgerung oder Best Practices


Die Hauptprobleme der Bedienerentwicklung im Moment sind die schwache Dokumentation der Tools und das Fehlen etablierter Best Practices. Wenn ein neuer Entwickler beginnt, einen Operator zu entwickeln, kann er sich Beispiele für die Implementierung einer bestimmten Anforderung praktisch nicht ansehen, sodass Fehler unvermeidlich sind. Im Folgenden finden Sie einige Lehren, die wir aus unseren Fehlern gezogen haben:


  • Wenn es zwei verwandte Anwendungen gibt, sollten Sie den Wunsch vermeiden, sie mit einem einzigen Operator zu kombinieren. Andernfalls wird das Prinzip der losen Kopplungsdienste verletzt.
  • Sie müssen sich an die Trennung von Bedenken erinnern: Sie sollten nicht versuchen, die gesamte Logik in einem Controller zu implementieren. Zum Beispiel lohnt es sich, die Funktionen der Überwachung von Konfigurationen und der Erstellung / Aktualisierung einer Ressource zu erweitern.
  • Das Blockieren von Anrufen sollte bei der Reconcile Methode vermieden werden. Sie können beispielsweise Konfigurationen von einer externen Quelle abrufen. Wenn der Vorgang jedoch länger dauert, erstellen Sie hierfür eine Goroutine und senden Sie die Anforderung an die Warteschlange zurück. Requeue: true in der Antwort Requeue: true .

In den Kommentaren wäre es interessant, von Ihren Erfahrungen bei der Entwicklung von Betreibern zu hören. Und im nächsten Teil werden wir über Bedienertests sprechen.

Source: https://habr.com/ru/post/de446648/


All Articles