
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

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 .

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:

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.