Alle Rezepte
Go · controller-runtime · Operator-Entwicklung

Von := bis Reconcile:
Go für Platform Engineers

Elf Etappen, rund 20 Stunden. Kein Sprachkurs für Anfänger, sondern der kürzeste Weg von „ich lese Go" zu „ich schreibe einen Operator, der in Produktion laufen darf". Jede Etappe erklärt, wofür der Sprachbaustein im Controller später steht.

NAME READY PHASE AGE go-skills/aleks 0/11 Progressing 0s Ziel: NamespaceLease-Operator, gebaut mit kubebuilder und controller-runtime
0Werkzeug & Reflexe20 min · Toolchain 1Sprachkern2 h · Zero Values, Sichtbarkeit 2Datentypen2 h · Slice, Map, Struct, Pointer 3Fehler1,5 h · error als Wert 4Interfaces2 h · Methoden, Embedding 5API-Typen1,5 h · Struct Tags, Marker 6Module1 h · go.mod, Layout 7Context2 h · Goroutines, Channels 8Tests1,5 h · table-driven, envtest 9controller-runtime2 h · Scheme, Client, Cache 10Der Operator5 h · kubebuilder, Reconcile
0

Werkzeug und Reflexe

20 Minuten, einmalig. Go bringt Build, Test, Formatter, Linter und Doku-Server in einem Binary mit – kein virtualenv, kein requirements.txt, keine Runtime auf dem Zielsystem.

# Fedora
sudo dnf install golang
go install golang.org/x/tools/gopls@latest          # Language Server
go install honnef.co/go/tools/cmd/staticcheck@latest

# neues Projekt
mkdir lernen && cd lernen
go mod init example.com/lernen
go run .          # kompiliert und startet
go build -o app . # ein statisches Binary, keine Abhängigkeiten
gofmt -l -w .     # Formatierung ist nicht verhandelbar
go vet ./...      # findet Unsinn, den der Compiler durchlässt
Genau diese Kette steckt später im Makefile, das kubebuilder erzeugt. make build ruft nichts anderes auf.
Go ist Bash mit Typen – dieselbe Nähe zum System, aber ein Compiler, der meckert, bevor der Kunde es tut.
Ein main.go, das os.Args ausgibt, bauen und das Binary in einen leeren Container kopieren (FROM scratch). Es läuft. Das ist der Grund, warum das CNCF-Ökosystem in Go geschrieben ist.
1

Sprachkern

2 Stunden, parallel dazu A Tour of Go bis zum Kapitel „Methods". Der Kern passt auf eine Seite – wichtig sind drei Eigenschaften, die es so anderswo nicht gibt.

package main

import (
	"fmt"
	"strings"
)

func main() {
	name := "openshift"        // := deklariert und weist zu, Typ wird abgeleitet
	var replicas int32 = 3     // var, wenn der exakte Typ wichtig ist
	var ready bool             // Zero Value: false. Nie undefined, nie None

	if n := strings.ToUpper(name); len(n) > 3 {
		fmt.Println(n, replicas, ready)   // n lebt nur im if-Block
	}

	for i := 0; i < 3; i++ { }        // die einzige Schleifenform
	for i, c := range name { _ = i; _ = c }
}

// Mehrfachrückgabe ist das Herzstück der Sprache
func parse(s string) (int, error) { /* ... */ }

Was hängenbleiben muss

  • Zero Values – jede Variable ist sofort gültig: 0, "", false, nil
  • Großschreibung ist Sichtbarkeit – Spec ist exportiert, spec paketprivat. Es gibt kein public
  • Ungenutzte Variablen und Imports sind Compile-Fehler – der Grund, warum Go-Repos wenig Leichen enthalten
  • _ ist der Mülleimer für Werte, die nicht gebraucht werden
Der Doppelpunkt ist die Geburtsurkunde – := bringt die Variable auf die Welt, = weist einer schon existierenden zu.
Achtung: := in einem inneren Block erzeugt eine neue Variable, auch wenn außen eine gleichnamige existiert. Der Klassiker ist err: innen wird der Fehler gesetzt, außen bleibt er nil, der Code läuft scheinbar sauber durch. go vet und staticcheck finden das meistens.
Eine Funktion splitImage(ref string) (registry, image, tag string, err error), die quay.io/openshift/cli:4.18 zerlegt und einen Fehler liefert, wenn kein Tag angegeben ist.
2

Slices, Maps, Structs, Pointer

2 Stunden. Diese vier machen 90 Prozent aller Zeilen in einem Controller aus. Entscheidend ist nicht die Syntax, sondern wann Go kopiert und wann es dieselbe Speicherstelle weiterreicht.

pods := []string{"a", "b"}       // Slice: dynamische Liste
pods = append(pods, "c")         // append gibt ein NEUES Slice zurück

labels := map[string]string{"app": "api"}
v, ok := labels["app"]           // comma-ok: existiert der Key?
delete(labels, "app")

type Lease struct {
	Name string
	TTL  time.Duration
}
l := Lease{Name: "team-a"}       // Struct ist ein Wert und wird beim Zuweisen KOPIERT
p := &l                          // Pointer auf dieselbe Speicherstelle
p.TTL = time.Hour                // kein -> nötig, Go dereferenziert selbst

Wert oder Pointer?

SituationRegel
Funktion soll das Objekt ändernPointer
Struct ist großPointer – K8s-Objekte sind groß
Feld ist optionalPointer, wenn „nicht gesetzt" etwas anderes ist als „0"
kleiner unveränderlicher WertWert
Slice ist ein Fenster auf eine Wiese – Zeiger, Länge, Kapazität. Zwei Slices können durch dasselbe Fenster schauen. Wer das Gras schneidet, verändert es für beide.
Achtung: Das Schreiben in eine nil-Map bricht zur Laufzeit ab, lesen dagegen funktioniert klaglos und liefert den Zero Value. Der Fehler taucht deshalb erst auf, wenn ein Objekt zum ersten Mal ohne Labels ankommt. Darum steht in jedem Controller: if obj.Annotations == nil { obj.Annotations = map[string]string{} }
replicas *int32 in der Deployment-Spec ist kein Zufall: nur ein Pointer unterscheidet „auf 0 gesetzt" von „gar nicht angegeben". Genau das wird in der eigenen CRD gebraucht.
Ein Typ NamespaceReport mit Name, angeforderter und genutzter CPU, dazu eine Funktion, die aus einem []NamespaceReport alle mit Auslastung unter 20 Prozent filtert.
3

Fehler sind Rückgabewerte

1,5 Stunden – der größte Umgewöhnungsbrocken. Go hat keine Exceptions. Ein Fehler ist ein normaler Rückgabewert, den man behandelt oder mit Kontext weiterreicht. panic ist für „der Prozess ist kaputt", nicht für „die API antwortet mit 404".

ns, err := getNamespace(ctx, "team-a")
if err != nil {
	return fmt.Errorf("namespace team-a lesen: %w", err)  // %w verpackt, kein Kontextverlust
}

// auspacken statt Stringvergleich
if apierrors.IsNotFound(err) { /* Objekt existiert nicht mehr */ }
if errors.Is(err, context.DeadlineExceeded) { /* Timeout */ }

defer f.Close()   // läuft beim Verlassen der Funktion, auf jedem Pfad
Fehler sind Rückgabefracht, kein Feueralarm – Paket entgegennehmen, eigenes Absenderlabel draufkleben, weiter nach oben schicken, bis jemand zuständig ist.
In Reconcile entscheidet der zurückgegebene Fehler, ob controller-runtime das Objekt mit Backoff erneut in die Warteschlange legt. return nil heißt „fertig", return err heißt „nochmal, später". Einen Fehler zu schlucken ist im Operator ein echter Bug.
Achtung: if err != nil { return err } ohne %w-Kontext ist die häufigste stille Fehlerquelle. Der Operator läuft, im Log steht connection refused, und niemand weiß, welcher der zwölf Client-Aufrufe es war. Der Fehler ist da, aber unbrauchbar.
Ein var ErrNoQuota = errors.New("kein ResourceQuota im Namespace") aus einer Funktion zurückgeben, zweimal mit %w verpacken und oben mit errors.Is wieder einfangen.
4

Methoden und Interfaces

2 Stunden. Hier klickt es oder nicht. Go kennt keine Klassen und keine Vererbung – stattdessen Methoden auf beliebigen Typen, implizit erfüllte Interfaces und eingebettete Structs.

type Reconciler struct { Client client.Client }

// Pointer-Receiver darf r verändern. Value-Receiver arbeitet auf einer Kopie.
func (r *Reconciler) Reconcile(ctx context.Context) error { /* ... */ }

// Ein Interface ist nur eine Liste geforderter Methoden. Mehr nicht.
type Notifier interface {
	Notify(msg string) error
}

// Kein "implements": Wer die Methode hat, erfüllt das Interface automatisch.
type SlackNotifier struct{}
func (s SlackNotifier) Notify(msg string) error { return nil }
Ein Interface ist eine Stellenanzeige, kein Arbeitsvertrag – wer die geforderten Fähigkeiten mitbringt, hat den Job. Eine Bewerbung war nie nötig.

Embedding statt Vererbung

type NamespaceLease struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`
	Spec   NamespaceLeaseSpec   `json:"spec,omitempty"`
	Status NamespaceLeaseStatus `json:"status,omitempty"`
}
// lease.Name funktioniert, weil ObjectMeta eingebettet ist – nicht, weil es geerbt wurde.
// Genau so ist jedes einzelne Kubernetes-Objekt aufgebaut.
client.Client ist ein Interface, keine konkrete Struktur. Deshalb lässt sich im Test ein Fake-Client einsetzen, der ohne API-Server auskommt. Interfaces sind der Grund, warum Operator-Tests in Millisekunden statt Minuten laufen.
Achtung: Value- und Pointer-Receiver nicht im selben Typ mischen. *T erfüllt Interfaces mit Value-Receivern, T aber nicht die mit Pointer-Receivern. Der Compilerfehler nennt dann einen Typ, der auf den ersten Blick alle Methoden hat. Einmal entscheiden, im Zweifel Pointer.
Ein Interface Scanner mit Scan(ctx context.Context) ([]Finding, error), zwei Implementierungen – eine liefert Ergebnisse, eine einen Fehler – und eine Funktion, die ein []Scanner nacheinander abarbeitet.
5

Structs als API-Typen

1,5 Stunden. Ab hier ist der Go-Code keine Übung mehr, sondern bereits die CRD: Struct Tags bestimmen das YAML, Kommentare werden zur Doku, Marker zur Validierung im API-Server.

type NamespaceLeaseSpec struct {
	// Auf welchen Namespace sich der Lease bezieht.
	// +kubebuilder:validation:MinLength=1
	NamespaceRef string `json:"namespaceRef"`

	// Lebensdauer ab Erstellung, z. B. "72h".
	// +kubebuilder:default="24h"
	TTL metav1.Duration `json:"ttl,omitempty"`

	// Nur markieren statt löschen. Nicht gesetzt ist etwas anderes als false.
	// +optional
	DryRun *bool `json:"dryRun,omitempty"`
}
ElementWirkung
Struct TagFeldname im YAML, omitempty lässt leere Felder weg
Kommentar über dem Feldwird zur Beschreibung in oc explain
+kubebuilder-MarkerOpenAPI-Validierung und Defaults im CRD
Pointer-Typunterscheidet „nicht gesetzt" von „auf false gesetzt"
Der Struct Tag ist das Adressetikett am Paket – der Inhalt bleibt gleich, das Etikett entscheidet, wo im YAML er ankommt.
Achtung: Ein optionales Bool ohne Pointer ist ein stiller Fehler. DryRun bool liefert für „Feld weggelassen" und für „dryRun: false" denselben Wert – der Operator löscht dann Namespaces, bei denen der Anwender das Feld nur vergessen hat. Validierung und Tests bemerken das nicht.
Die Spec eines RightSizingPolicy-Typs: Namespace-Selector, Perzentil als *int32 mit Default 95, Beobachtungsfenster als metav1.Duration. Anschließend mit sigs.k8s.io/yaml serialisieren und das erzeugte YAML prüfen.
6

Module und Projektlayout

1 Stunde. Ein Verzeichnis ist ein Package, der Importpfad ist die Repository-URL, eine zentrale Registry gibt es nicht – go get zieht direkt aus Git.

go mod init github.com/shushyu/lease-operator
go get sigs.k8s.io/controller-runtime@latest
go mod tidy      # räumt go.mod und go.sum auf – vor jedem Commit
go doc sigs.k8s.io/controller-runtime/pkg/client Client
PfadInhalt
cmd/main.goManager starten, Reconciler registrieren
api/v1alpha1/CRD-Typen und generierter DeepCopy-Code
internal/controller/Reconcile-Logik. internal/ ist von außen nicht importierbar
config/generierte CRDs, RBAC, Kustomize-Overlays
Genau dieses Layout legt kubebuilder init an. Wer es jetzt versteht, für den ist der generierte Code in Etappe 10 keine Blackbox.
7

Goroutines und Context

2 Stunden. Context wird täglich gebraucht, Channels seltener als gedacht – Warteschlange und Worker-Pool liefert controller-runtime.

go doWork()                  // Goroutine: kostet Kilobytes, nicht Megabytes

ch := make(chan string)
go func() { ch <- "fertig" }()
msg := <-ch                  // blockiert, bis etwas ankommt

ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()               // immer – sonst bleibt der Timer liegen

select {
case r := <-ch:
	use(r)
case <-ctx.Done():
	return ctx.Err()
}
Context ist die Leine am Hund – jede API-Funktion bekommt sie als erstes Argument gereicht. Zieht man oben an der Leine, bleiben alle unten sofort stehen.
Im eigenen Operator entstehen kaum eigene Goroutines. Gebraucht wird zweierlei: ctx in jeden Client-Aufruf durchreichen und verstehen, warum ein Reconcile abbricht, wenn der Manager herunterfährt.
Achtung: Wer ctx in einem Struct speichert oder mitten im Reconcile ein context.Background() erzeugt, hebelt das Shutdown-Handling aus. Im Betrieb fällt das nicht auf – erst beim Rollout hängen Pods im Terminating, weil laufende API-Aufrufe niemand mehr abbricht.
Drei URLs parallel abfragen, Ergebnisse über einen Channel einsammeln, mit sync.WaitGroup auf das Ende warten und nach 2 Sekunden per Context abbrechen.
8

Tests, die sich lohnen

1,5 Stunden. Tests folgen in Go fast immer demselben Muster: eine Tabelle mit Fällen, eine Schleife, t.Run pro Fall.

func TestExpired(t *testing.T) {
	tests := []struct {
		name string
		age  time.Duration
		ttl  time.Duration
		want bool
	}{
		{"frisch", time.Hour, 24 * time.Hour, false},
		{"abgelaufen", 48 * time.Hour, 24 * time.Hour, true},
		{"exakt auf der Grenze", 24 * time.Hour, 24 * time.Hour, true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if got := expired(tt.age, tt.ttl); got != tt.want {
				t.Errorf("expired() = %v, want %v", got, tt.want)
			}
		})
	}
}
go test ./... -race *_test.go envtest
Für den Operator gibt es zwei Ebenen: reine Logik wie oben – schnell und in Millisekunden – und das Zusammenspiel mit der API über envtest, das einen echten kube-apiserver samt etcd als lokale Prozesse startet. Kein Cluster nötig; kubebuilder legt das Gerüst dafür an.
Table-driven Test für die splitImage-Funktion aus Etappe 1, inklusive der Fehlerfälle.
9

client-go und controller-runtime

2 Stunden. Erst das Modell verstehen, dann Code schreiben. Vier Begriffe erklären das gesamte Framework.

BegriffAufgabe
SchemeÜbersetzungstabelle Go-Typ ↔ GroupVersionKind. Nicht registriert bedeutet Fehler beim Start
ClientGet, List, Create, Update, Patch, Delete. Lesen aus dem Cache, Schreiben direkt an den API-Server
Cache / InformerWatch auf den API-Server, lokale Kopie im Speicher, indiziert
ManagerStartet Cache, Client, Leader Election, Metrics, Health-Probes und alle Controller
Der Informer ist der Praktikant am Ticker – er sortiert jede Änderung ins Regal ein. Gelesen wird immer aus dem Regal, nie beim Amt nachgefragt. Geschrieben wird aber nie ins Regal, sondern als Antrag ans Amt.

Der Reconcile-Vertrag

  1. Nur Name und Namespace kommen anDas Objekt holt sich der Controller selbst. Es kann inzwischen gelöscht sein.
  2. Keine Auskunft darüber, was sich geändert hatEs gibt kein Event-Objekt. Ist- und Sollzustand werden bei jedem Lauf komplett verglichen.
  3. Idempotenz ist PflichtHundert Aufrufe müssen dasselbe Ergebnis liefern wie einer – der Controller wird ohne erkennbaren Grund erneut aufgerufen.
  4. Die Rückgabe steuert die Warteschlange{}, nil = fertig · {}, err = Retry mit Backoff · {RequeueAfter: d}, nil = erneut nach Ablauf von d.
Achtung: Das aus dem Cache gelesene Objekt gehört dem Prozess, nicht dem Controller. Wird es direkt verändert, ändert sich der Cache für alle Controller im selben Manager – ohne Fehlermeldung, mit Auswirkungen an ganz anderer Stelle. Vor jeder Änderung obj.DeepCopy().
Achtung: spec und status sind getrennte Subresources. r.Update() schreibt die Spec, r.Status().Update() den Status. Wer den falschen Aufruf nimmt, bekommt keinen Fehler – die Änderung verschwindet einfach und die Conditions bleiben für immer leer.
10

Der Operator: NamespaceLease

5 Stunden. Eine CR beschreibt, wie lange ein Namespace leben darf. Läuft die TTL ab, markiert der Operator den Namespace per Annotation – klein genug für ein Wochenende, groß genug für alle echten Konzepte.

kubebuilder init --domain kubekoch.de --repo github.com/shushyu/lease-operator
kubebuilder create api --group ops --version v1alpha1 --kind NamespaceLease
# erzeugt api/v1alpha1/namespacelease_types.go und internal/controller/...

make manifests generate   # CRD, RBAC und DeepCopy-Code aus den Markern erzeugen
make install              # CRD in den Cluster einspielen
make run                  # Operator lokal gegen den Cluster, mit dem eigenen kubeconfig

Die Reconcile-Funktion

// +kubebuilder:rbac:groups=ops.kubekoch.de,resources=namespaceleases,verbs=get;list;watch;update
// +kubebuilder:rbac:groups=ops.kubekoch.de,resources=namespaceleases/status,verbs=get;update;patch
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;update

func (r *NamespaceLeaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var lease opsv1alpha1.NamespaceLease
	if err := r.Get(ctx, req.NamespacedName, &lease); err != nil {
		// Objekt gelöscht? Dann ist nichts mehr zu tun.
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	deadline := lease.CreationTimestamp.Add(lease.Spec.TTL.Duration)
	if remaining := time.Until(deadline); remaining > 0 {
		// Noch Zeit: exakt dann wieder aufwachen, wenn sie abläuft.
		return ctrl.Result{RequeueAfter: remaining}, nil
	}

	var ns corev1.Namespace
	if err := r.Get(ctx, client.ObjectKey{Name: lease.Spec.NamespaceRef}, &ns); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	patched := ns.DeepCopy()                      // Cache-Objekt nie direkt anfassen
	if patched.Annotations == nil {
		patched.Annotations = map[string]string{}
	}
	patched.Annotations["lease.kubekoch.de/expired"] = "true"
	if err := r.Update(ctx, patched); err != nil {
		return ctrl.Result{}, fmt.Errorf("namespace %s markieren: %w", ns.Name, err)
	}

	meta.SetStatusCondition(&lease.Status.Conditions, metav1.Condition{
		Type:    "Ready",
		Status:  metav1.ConditionFalse,
		Reason:  "TTLExpired",
		Message: "Lease abgelaufen, Namespace markiert",
	})
	return ctrl.Result{}, r.Status().Update(ctx, &lease)
}

func (r *NamespaceLeaseReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&opsv1alpha1.NamespaceLease{}).
		Owns(&corev1.Namespace{}).    // Änderung am Namespace triggert den Lease
		Complete(r)
}
IgnoreNotFound verhindert Endlos-Retries bei gelöschten Objekten. RequeueAfter ist der Timer, den man sonst selbst bauen müsste – und der einen Neustart nicht überleben würde. Conditions sind der Kubernetes-Standard für den Zustand und werden von oc describe direkt angezeigt. Bemerkenswert ist, was nicht im Code steht: keine Schleife, kein Sleep, kein Watch, keine Warteschlange, kein Retry.
Achtung: Fehlt ein RBAC-Marker, läuft alles lokal fehlerfrei – make run nutzt die Rechte des angemeldeten Benutzers. Erst im Cluster, mit dem ServiceAccount des Operators, kommt der 403. Nach jeder neuen Ressource im Code deshalb make manifests und die erzeugte ClusterRole prüfen.

Ausbaustufen in dieser Reihenfolge

  • Finalizer – aufräumen, bevor die CR verschwindet. Der klassische zweite Schritt
  • Events – r.Recorder.Event(...), damit oc describe etwas erzählt
  • printcolumn – // +kubebuilder:printcolumn:name="Expires",type=date,JSONPath=".status.expiresAt"
  • Konflikte – bei apierrors.IsConflict(err) den Fehler einfach zurückgeben, die Warteschlange erledigt den Retry
  • Bundle – operator-sdk für OLM-Bundles, wenn der Operator über den OperatorHub laufen soll
Abschlussaufgabe: Den Lease um spec.warnBefore erweitern – 24 Stunden vor Ablauf ein Event feuern und eine Condition ExpiringSoon=True setzen, ohne einen einzigen zusätzlichen Timer im Code. Lösungsweg: zwei mögliche RequeueAfter-Zeitpunkte berechnen und den nächstgelegenen zurückgeben.
++

Fallenliste

Die Fehler, die jeder genau einmal macht. Fast alle melden nichts – der Code läuft, nur das Ergebnis stimmt nicht.

SymptomUrsache
assignment to entry in nil mapMap vor dem Schreiben nicht initialisiert
Status bleibt leerUpdate() statt Status().Update(), oder Subresource-Marker fehlt
Andere Controller sehen falsche DatenCache-Objekt mutiert statt DeepCopy()
CPU dauerhaft hochReconcile schreibt bei jedem Lauf, also nicht idempotent
403 im Cluster, lokal grünRBAC-Marker fehlt oder make manifests nicht gelaufen
err ist nil, obwohl es scheiterteShadowing durch := im inneren Block
object has been modifiedNormaler Konflikt. Fehler zurückgeben, nicht in einer Schleife wiederholen
no kind registered for versionTyp nicht im Scheme registriert
++

Quellen in Lesereihenfolge

Fünf Anlaufstellen reichen. Alles andere ist Ablenkung, solange der erste Operator nicht läuft.