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
Makefile, das kubebuilder erzeugt. make build ruft nichts anderes auf.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.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 –
Specist exportiert,specpaketprivat. Es gibt keinpublic - 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
:= bringt die Variable auf die Welt, = weist einer schon existierenden zu.:= 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.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.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?
| Situation | Regel |
|---|---|
| Funktion soll das Objekt ändern | Pointer |
| Struct ist groß | Pointer – K8s-Objekte sind groß |
| Feld ist optional | Pointer, wenn „nicht gesetzt" etwas anderes ist als „0" |
| kleiner unveränderlicher Wert | Wert |
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.NamespaceReport mit Name, angeforderter und genutzter CPU, dazu eine Funktion, die aus einem []NamespaceReport alle mit Auslastung unter 20 Prozent filtert.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
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.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.var ErrNoQuota = errors.New("kein ResourceQuota im Namespace") aus einer Funktion zurückgeben, zweimal mit %w verpacken und oben mit errors.Is wieder einfangen.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 }
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.*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.Scanner mit Scan(ctx context.Context) ([]Finding, error), zwei Implementierungen – eine liefert Ergebnisse, eine einen Fehler – und eine Funktion, die ein []Scanner nacheinander abarbeitet.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"`
}
| Element | Wirkung |
|---|---|
| Struct Tag | Feldname im YAML, omitempty lässt leere Felder weg |
| Kommentar über dem Feld | wird zur Beschreibung in oc explain |
| +kubebuilder-Marker | OpenAPI-Validierung und Defaults im CRD |
| Pointer-Typ | unterscheidet „nicht gesetzt" von „auf false gesetzt" |
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.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.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
| Pfad | Inhalt |
|---|---|
| cmd/main.go | Manager 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 |
kubebuilder init an. Wer es jetzt versteht, für den ist der generierte Code in Etappe 10 keine Blackbox.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()
}
ctx in jeden Client-Aufruf durchreichen und verstehen, warum ein Reconcile abbricht, wenn der Manager herunterfährt.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.sync.WaitGroup auf das Ende warten und nach 2 Sekunden per Context abbrechen.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)
}
})
}
}
envtest, das einen echten kube-apiserver samt etcd als lokale Prozesse startet. Kein Cluster nötig; kubebuilder legt das Gerüst dafür an.splitImage-Funktion aus Etappe 1, inklusive der Fehlerfälle.client-go und controller-runtime
2 Stunden. Erst das Modell verstehen, dann Code schreiben. Vier Begriffe erklären das gesamte Framework.
| Begriff | Aufgabe |
|---|---|
| Scheme | Übersetzungstabelle Go-Typ ↔ GroupVersionKind. Nicht registriert bedeutet Fehler beim Start |
| Client | Get, List, Create, Update, Patch, Delete. Lesen aus dem Cache, Schreiben direkt an den API-Server |
| Cache / Informer | Watch auf den API-Server, lokale Kopie im Speicher, indiziert |
| Manager | Startet Cache, Client, Leader Election, Metrics, Health-Probes und alle Controller |
Der Reconcile-Vertrag
- Nur Name und Namespace kommen anDas Objekt holt sich der Controller selbst. Es kann inzwischen gelöscht sein.
- Keine Auskunft darüber, was sich geändert hatEs gibt kein Event-Objekt. Ist- und Sollzustand werden bei jedem Lauf komplett verglichen.
- Idempotenz ist PflichtHundert Aufrufe müssen dasselbe Ergebnis liefern wie einer – der Controller wird ohne erkennbaren Grund erneut aufgerufen.
- Die Rückgabe steuert die Warteschlange
{}, nil= fertig ·{}, err= Retry mit Backoff ·{RequeueAfter: d}, nil= erneut nach Ablauf von d.
obj.DeepCopy().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.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.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(...), damitoc describeetwas 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-sdkfür OLM-Bundles, wenn der Operator über den OperatorHub laufen soll
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.
| Symptom | Ursache |
|---|---|
| assignment to entry in nil map | Map vor dem Schreiben nicht initialisiert |
| Status bleibt leer | Update() statt Status().Update(), oder Subresource-Marker fehlt |
| Andere Controller sehen falsche Daten | Cache-Objekt mutiert statt DeepCopy() |
| CPU dauerhaft hoch | Reconcile schreibt bei jedem Lauf, also nicht idempotent |
| 403 im Cluster, lokal grün | RBAC-Marker fehlt oder make manifests nicht gelaufen |
| err ist nil, obwohl es scheiterte | Shadowing durch := im inneren Block |
| object has been modified | Normaler Konflikt. Fehler zurückgeben, nicht in einer Schleife wiederholen |
| no kind registered for version | Typ nicht im Scheme registriert |
Quellen in Lesereihenfolge
Fünf Anlaufstellen reichen. Alles andere ist Ablenkung, solange der erste Operator nicht läuft.
- A Tour of Go – Etappen 1 bis 4, interaktiv, ein Nachmittag
- Go by Example – Nachschlagewerk, wenn eine Syntax fehlt
- Effective Go – warum Go so aussieht, wie es aussieht
- The Kubebuilder Book – Etappen 9 und 10, die eigentliche Referenz
- kubernetes/sample-controller – derselbe Mechanismus ohne Framework. Erst lesen, wenn der eigene Operator läuft