Crossplane verwaltet Cloud-Infrastruktur über die Kubernetes-API. Du beschreibst einen Ablageort oder eine Datenbank als Objekt im Cluster, und ein Controller sorgt dafür, dass die echte Ressource beim Anbieter entsteht und in genau diesem Zustand bleibt. Der Unterschied zu einem klassischen Infrastruktur-Werkzeug liegt weniger im Beschreiben als im Betrieb: Es gibt keinen Lauf, den jemand anstößt, sondern einen Regelkreis, der nie aufhört.
Dieser Beitrag ist ein vollständiges Tutorial und setzt keine Crossplane-Kenntnisse voraus. Es beginnt bei der Installation, geht über den ersten Provider, die erste verwaltete Ressource und die eigene Plattform-API und endet bei den Stellen, an denen es unangenehm wird: beim Löschen, bei der Drift und bei der Zahl der Ressourcentypen, die ein Provider in den Cluster schreibt. Alles hier ist gegen Crossplane 2.3 geschrieben, also gegen die Version, in der Composite und Managed Resources standardmäßig im Namespace liegen.
Inhalt
- Der Werkzeug-Shop und das Ticket, das drei Tage liegt
- Was Crossplane ist und warum es im Cluster wohnt
- Was du brauchst
- Crossplane installieren
- Der Provider: Crossplane lernt Objektspeicher
- Zugangsdaten und ProviderConfig
- Die erste verwaltete Ressource
- Was der Controller tut, während du wartest
- Der Test, der Crossplane von einem Lauf trennt
- Das Ziel: eine eigene Bestellkarte für den Shop
- Die XRD: die Bestellkarte definieren
- Die Composition: was hinter der Bestellkarte passiert
- Patches: wie der Wunsch in die Ressourcen kommt
- Der Entwickler bestellt sich seine Ablage
- Namespaces statt Claims: was Version 2 geändert hat
- Status zurückgeben: was der Entwickler sehen soll
- Löschen: die Regel, die einmal wehtut
- Die vollständigen Manifeste
- Die Bausteine auf einen Blick
- Crossplane oder ein Infrastruktur-Lauf?
- Häufige Fallstricke
- Wann sich Crossplane lohnt, wann nicht
- FAQ
- Fazit
- Quellen
Der Werkzeug-Shop und das Ticket, das drei Tage liegt
Ein Onlineshop verkauft Werkzeug: Bohrmaschinen, Stichsägen, Akkuschrauber, dazu Zubehör und Ersatzteile. Der Shop besteht längst nicht mehr aus einer Anwendung, sondern aus einer Handvoll Diensten. Katalog, Bewertungen, Ersatzteilsuche, jeder mit eigenem Team.
Jeder dieser Dienste braucht dieselbe Sorte Ablage. Produktbilder in mehreren Größen, dazu Montageanleitungen als PDF, denn wer eine Stichsäge kauft, will die Anleitung im Shop sehen und nicht erst im Karton finden. Die Regeln sind überall dieselben: Versionierung an, damit ein versehentlich überschriebenes Bild zurückgeholt werden kann, und von außen ist nichts öffentlich erreichbar, weil vor den Dateien ein Auslieferungsdienst steht.
Der Weg dorthin führt über ein Ticket. Ein Team meldet den Bedarf, das Plattformteam trägt einen Block in die Infrastruktur-Konfiguration ein, jemand prüft den Plan, jemand wendet an. Im guten Fall dauert das einen Tag, im normalen drei, und in der Woche vor einem Release dauert es länger, weil dann alle gleichzeitig etwas wollen.
Zweimal ist dabei etwas schiefgegangen, und beide Male auf dieselbe Weise. Beim Anlegen der Ablage für die Ersatzteilsuche fehlte die Versionierung, weil der kopierte Block aus einer älteren Stelle stammte. Und als der Kollege, der die Ablage für die Bewertungen gebaut hat, den Zugriff kurz von Hand aufgemacht hat, um eine Datei zu prüfen, hat er das Zumachen vergessen. Aufgefallen ist beides Wochen später.
Unaufmerksam war dabei niemand. Dieses Vorgehen lässt beide Fehler strukturell zu: Kopieren ohne Kontrolle, und ein Handgriff zwischen zwei Läufen, den niemand bemerkt. Der Rest dieses Artikels baut dieselbe Ablage noch einmal auf, aber so, dass beide Fehler nicht mehr hineinpassen. Am Ende schreibt das Team der Ersatzteilsuche acht Zeilen YAML in seinen eigenen Namespace und bekommt eine Ablage, die den Regeln entspricht und ihnen auch dann wieder entspricht, wenn jemand von Hand daran dreht.
Was Crossplane ist und warum es im Cluster wohnt
Crossplane ist ein Open-Source-Projekt der Cloud Native Computing Foundation und hat dort Ende Oktober 2025 den Status „Graduated“ erreicht, also die höchste Reifestufe. Es erweitert Kubernetes um die Fähigkeit, Ressourcen außerhalb des Clusters zu verwalten.
Der Kern ist eine Idee, die Kubernetes ohnehin schon trägt. Du legst ein Objekt an, das einen Wunschzustand beschreibt, und ein Controller arbeitet unablässig daran, die Wirklichkeit diesem Wunsch anzugleichen. Bei einem Deployment ist die Wirklichkeit eine Menge laufender Pods. Bei Crossplane ist die Wirklichkeit ein Objektspeicher, eine Datenbank oder ein Netzwerk beim Cloud-Anbieter.
Daraus folgt der Unterschied, der in der Praxis am meisten ausmacht. Ein Infrastruktur-Lauf ist ein Ereignis: Er beginnt, er endet, und danach schaut niemand mehr hin. Ein Controller ist ein Zustand: Er vergleicht Soll und Ist in einem festen Takt und korrigiert, was abweicht. Wer eine verwaltete Einstellung von Hand ändert, findet sie beim nächsten Abgleich zurückgesetzt vor.
Der zweite Unterschied ist die Zugriffskontrolle. Weil alles ein Kubernetes-Objekt ist, gelten die Regeln, die im Cluster ohnehin gelten. Wer eine Ablage bestellen darf und wer nicht, ist eine RBAC-Frage und keine Frage, wer Zugang zur Infrastruktur-Konfiguration hat.
Der Preis dafür ist ebenso deutlich: Der Cluster wird selbst zu Produktionsinfrastruktur. Er muss gepatcht, gesichert und überwacht werden, auch für Ressourcen, die mit Kubernetes nichts zu tun haben. Wer diesen Preis nicht zahlen will, ist mit einem Lauf besser bedient. Darauf kommt der Abschnitt am Ende noch einmal zurück.
Was du brauchst
Für dieses Tutorial brauchst du einen Kubernetes-Cluster, auf dem du Administratorrechte hast. Für den Anfang reicht ein lokaler Cluster mit kind oder k3d, drei bis vier Gigabyte Arbeitsspeicher sollten frei sein. Dazu kubectl und helm.
Auf der Anbieterseite brauchst du Zugangsdaten mit Rechten auf den Objektspeicher. Die Beispiele hier nutzen den AWS-Provider und dessen S3-Ressourcen, weil er am besten dokumentiert ist. Das Vorgehen ist bei anderen Anbietern identisch, es ändern sich die Namen der Ressourcentypen und die Felder.
Alle Manifeste in diesem Artikel sind gegen Crossplane 2.3 geschrieben. Die Version ist wichtig, weil sich mit Version 2 die Objektmodelle geändert haben und die meisten Anleitungen im Netz noch das alte Modell zeigen. Woran du das erkennst, steht im Abschnitt über Namespaces und Claims.
Crossplane installieren
Crossplane wird per Helm installiert und legt seine eigenen Bestandteile in einen eigenen Namespace:
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update
helm install crossplane crossplane-stable/crossplane
--namespace crossplane-system
--create-namespaceDanach laufen zwei Pods, der Kern und ein Verwalter für Pakete:
kubectl get pods -n crossplane-systemNAME READY STATUS RESTARTS AGE
crossplane-7d4b8f9c56-2xk9p 1/1 Running 0 62s
crossplane-rbac-manager-6c9f7d4b8f-lm4qt 1/1 Running 0 62sInteressanter als die Pods ist, was Crossplane an der API des Clusters ergänzt hat:
kubectl api-resources --api-group=pkg.crossplane.ioNAME SHORTNAMES APIVERSION NAMESPACED KIND
configurations pkg.crossplane.io/v1 false Configuration
functions pkg.crossplane.io/v1 false Function
providers pkg.crossplane.io/v1 false ProviderDiese drei Typen sind der ganze Anfang. Ein Provider bringt das Wissen über einen Anbieter mit, eine Function verarbeitet Vorlagen, und eine Configuration bündelt beides zu einem Paket. Cloud-Ressourcen kennt Crossplane an dieser Stelle noch keine einzige.
Der Provider: Crossplane lernt Objektspeicher
Ein Provider ist ein Paket, das die API eines Anbieters kennt und für jeden Ressourcentyp eine eigene Kubernetes-Ressource in den Cluster schreibt. Nach der Installation kann der Cluster mit Bucket genauso umgehen wie mit Deployment.
Hier lauert die erste unangenehme Eigenschaft, deshalb steht sie am Anfang und nicht in den Fallstricken. Der große, monolithische AWS-Provider bringt über 900 Ressourcentypen mit. Jeder davon ist eine CustomResourceDefinition, und so viele auf einmal setzen die Kubernetes-API spürbar unter Druck. In dokumentierten Fällen war der API-Server während der anschließenden Skalierung des Cluster-Kerns bis zu einer Stunde nicht ansprechbar.
Die Antwort darauf heißt Provider Families. Statt eines Pakets für den gesamten Anbieter installierst du eines je Dienst, und es kommt nur in den Cluster, was du tatsächlich brauchst:
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws-s3
spec:
package: xpkg.upbound.io/upbound/provider-aws-s3:v2.6.3Anwenden und warten, bis das Paket als gesund gemeldet wird:
kubectl apply -f provider.yaml
kubectl get providersNAME INSTALLED HEALTHY PACKAGE AGE
provider-aws-s3 True True xpkg.upbound.io/upbound/provider-aws-s3:v2.6.3 48sDer Provider hat jetzt seine Ressourcentypen mitgebracht. Statt der über 900 aus dem Monolithen sind es nur die des Objektspeichers:
kubectl api-resources --api-group=s3.aws.m.upbound.ioNAME APIVERSION NAMESPACED KIND
buckets s3.aws.m.upbound.io/v1beta1 true Bucket
bucketlifecycleconfigurations s3.aws.m.upbound.io/v1beta1 true BucketLifecycleConfiguration
bucketpublicaccessblocks s3.aws.m.upbound.io/v1beta1 true BucketPublicAccessBlock
bucketversionings s3.aws.m.upbound.io/v1beta1 true BucketVersioningDie Ausgabe ist auf die vier Typen gekürzt, die dieser Artikel braucht. Das Paket bringt weitere mit, etwa für Policies und Replikation, aber es bleibt bei gut zwei Dutzend statt über 900.
Zwei Dinge daran sind wichtig. Das m in s3.aws.m.upbound.io steht für das neue, namespace-fähige Modell von Crossplane 2. Und die Spalte NAMESPACED steht auf true, diese Ressourcen leben also in einem Namespace wie ein Deployment auch. Findest du in einer Anleitung eine Gruppe ohne das m, dann beschreibt sie das alte Modell.
Zugangsdaten und ProviderConfig
Der Provider weiß jetzt, wie ein Objektspeicher aussieht, aber nicht, in wessen Konto er ihn anlegen soll. Dafür brauchst du zwei Dinge: ein Secret mit den Zugangsdaten und eine Konfiguration, die darauf zeigt.
Zuerst der Namespace, in dem das Team arbeitet. Alles Weitere in diesem Artikel lebt darin:
kubectl create namespace team-ersatzteileDann die Zugangsdaten. Für ein Tutorial mit statischen Schlüsseln reicht eine kleine Datei:
[default]
aws_access_key_id = DEIN_KEY
aws_secret_access_key = DEIN_SECRETkubectl create secret generic aws-zugang
--namespace team-ersatzteile
--from-file=credentials=./zugangsdaten.txtFür den Produktivbetrieb ist das der falsche Weg, und das ist keine Formalie. Statische Schlüssel liegen dauerhaft im Cluster, sie lassen sich kopieren und sie laufen nicht ab. Der bessere Weg heißt Workload Identity: Der Provider-Pod bekommt ein kurzlebiges Token, das der Anbieter gegen eine Rolle eintauscht, und es entsteht überhaupt kein Schlüssel. Für den ersten Durchlauf bleibt es hier bei der einfachen Variante, im Abschnitt über Fallstricke steht, worauf du beim Umstieg achtest.
Jetzt die Konfiguration. Sie liegt im selben Namespace wie die Ressourcen, die sie bedienen soll:
apiVersion: aws.m.upbound.io/v1beta1
kind: ProviderConfig
metadata:
name: standard
namespace: team-ersatzteile
spec:
credentials:
source: Secret
secretRef:
namespace: team-ersatzteile
name: aws-zugang
key: credentialsAuch hier ist der Namespace der eigentliche Punkt. Eine ProviderConfig gilt nur für Ressourcen im selben Namespace. Wenn ein Konto clusterweit gelten soll, gibt es dafür einen zweiten Typ, ClusterProviderConfig, der ohne Namespace angelegt wird. Damit lässt sich sauber trennen: Das Team der Ersatzteilsuche arbeitet gegen ein anderes Konto als das Team der Bewertungen, ohne dass eines der Teams das Konto des anderen sehen kann.
Die erste verwaltete Ressource
Jetzt der erste Ablageort, direkt und ohne Abstraktion. Eine Ressource, die ein Objekt beim Anbieter vertritt, heißt Managed Resource:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: Bucket
metadata:
name: werkzeug-anleitungen
namespace: team-ersatzteile
spec:
forProvider:
region: eu-central-1
providerConfigRef:
kind: ProviderConfig
name: standardZwei Felder tragen die ganze Struktur. Unter forProvider steht alles, was der Anbieter selbst kennt, also genau die Felder aus seiner API. Alles darüber ist Crossplane-Sache: welche Konfiguration gilt, was beim Löschen passieren soll, wohin Verbindungsdaten geschrieben werden.
Anwenden und zusehen:
kubectl apply -f bucket.yaml
kubectl get bucket -n team-ersatzteile -wNAME SYNCED READY EXTERNAL-NAME AGE
werkzeug-anleitungen False 3s
werkzeug-anleitungen True False werkzeug-anleitungen 9s
werkzeug-anleitungen True True werkzeug-anleitungen 14sDie zwei Spalten SYNCED und READY sind der Grund, warum diese Ausgabe hier steht. SYNCED bedeutet, dass Crossplane mit der API des Anbieters sprechen konnte und der Wunsch angekommen ist. READY bedeutet, dass die Ressource beim Anbieter tatsächlich benutzbar ist. Zwischen beiden liegt die Zeit, die der Anbieter braucht, und bei einer Datenbank oder einem Cluster sind das Minuten statt Sekunden.
Bleibt SYNCED auf False, liegt der Grund fast immer bei den Zugangsdaten oder den Rechten. Die Ressource selbst sagt dir, woran es hakt:
kubectl describe bucket werkzeug-anleitungen -n team-ersatzteileWas der Controller tut, während du wartest
Bis hierher sieht das nach einem umständlichen Weg aus, einen Ablageort anzulegen. Der Unterschied zeigt sich erst danach, im Dauerbetrieb.
Für jede verwaltete Ressource läuft eine Schleife. Sie fragt den Anbieter nach dem Ist-Zustand, vergleicht ihn mit dem, was im Objekt steht, und schreibt die Differenz zurück. Findet sie keine Differenz, tut sie nichts und wartet auf den nächsten Durchgang.
Der Takt dieser Schleife ist einstellbar und lohnt einen Blick, bevor du dich auf sie verlässt. Der Crossplane-Kern selbst prüft standardmäßig jede Minute. Provider, die aus einem Terraform-Provider erzeugt wurden, und dazu gehören die großen Cloud-Provider, prüfen pro Ressource standardmäßig alle zehn Minuten. „Sofort“ ist es also nicht, und für eine Alarmierung taugt der Mechanismus nicht. Für das, was er leisten soll, reicht er: Eine Abweichung überlebt nicht dauerhaft.
Wichtig ist die Grenze dieser Aussage, und sie wird oft zu großzügig gezogen. Crossplane zieht ausschließlich Ressourcen nach, die es selbst verwaltet, und dort ausschließlich die Felder, die im Manifest stehen. Ein Ablageort, den jemand von Hand in der Konsole anlegt, bleibt unangetastet, denn Crossplane weiß nichts von ihm. Es ist kein Wächter über das Konto, sondern über seine eigenen Objekte.
Der Test, der Crossplane von einem Lauf trennt
Der von Hand geöffnete Zugriff aus der Eingangsgeschichte lässt sich jetzt nachstellen.
Zuerst der Riegel, der den öffentlichen Zugriff schließt, als eigene Ressource:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketPublicAccessBlock
metadata:
name: werkzeug-anleitungen-riegel
namespace: team-ersatzteile
spec:
forProvider:
region: eu-central-1
bucketRef:
name: werkzeug-anleitungen
blockPublicAcls: true
blockPublicPolicy: true
ignorePublicAcls: true
restrictPublicBuckets: true
providerConfigRef:
kind: ProviderConfig
name: standardbucketRef ist dabei mehr als eine Schreiberleichterung. Crossplane löst die Referenz auf, trägt den echten Namen ein und stellt damit auch die Reihenfolge her: Der Riegel wird erst gesetzt, wenn der Ablageort existiert.
Jetzt der Eingriff von Hand. Öffne den öffentlichen Zugriff in der Konsole des Anbieters oder mit dessen Kommandozeile, so wie es der Kollege in der Eingangsgeschichte getan hat. Dann warte den Takt ab und sieh dort wieder nach: Der Riegel ist zu.
Der Nachweis gehört ausdrücklich in die Konsole des Anbieters und nicht in den Cluster. Im Cluster hat sich nichts bewegt, und das lässt sich auch zeigen:
kubectl get bucketpublicaccessblock -n team-ersatzteileSYNCED und READY standen die ganze Zeit auf True, denn der Wunschzustand war nie weg. Korrigiert wurde beim Anbieter, nicht am Objekt.
Niemand hat einen Lauf gestartet, niemand hat den Eingriff bemerkt, und trotzdem steht der Zustand wieder da, wo er stehen soll. Das ist der Punkt, an dem sich das Modell auszahlt, und es ist auch der Punkt, an dem es unangenehm werden kann: Wer im Störfall bewusst von Hand eingreift, muss wissen, dass ihm der Controller in den Arm fällt. Für solche Fälle gibt es Management Policies, mit denen sich eine Ressource vorübergehend nur beobachten statt verwalten lässt.
Das Ziel: eine eigene Bestellkarte für den Shop
Bis hierher hat sich am Kern des Problems wenig geändert. Statt eines Blocks in der Infrastruktur-Konfiguration schreibt jetzt jemand drei YAML-Dateien, und dieser jemand muss wissen, was ein Public Access Block ist. Für ein Plattformteam ist das in Ordnung, für das Team der Ersatzteilsuche ist es zu viel.
Dafür gibt es den Teil von Crossplane, der den Aufwand rechtfertigt. Du definierst einen eigenen Ressourcentyp, der so aussieht, wie dein Unternehmen denkt, und legst fest, was hinter ihm passiert. Für den Shop heißt der Typ ShopAblage, und ein Team bestellt so:
apiVersion: shop.werkzeugversand.example/v1
kind: ShopAblage
metadata:
name: anleitungen
namespace: team-ersatzteile
spec:
region: eu-central-1
aufbewahrungTage: 30Keine Rede mehr von Riegeln und Versionierung. Diese Regeln gelten, weil sie in der Vorlage stehen, und nicht, weil jemand daran gedacht hat. Der Fehler aus der Eingangsgeschichte, der fehlende Block im kopierten Abschnitt, hat hier keinen Platz mehr, denn es gibt nichts mehr zu kopieren.
Zwei Bausteine gehören dazu. Die CompositeResourceDefinition, kurz XRD, beschreibt die Bestellkarte: wie der Typ heißt und welche Felder er hat. Die Composition beschreibt, was auf die Bestellung hin entsteht.
Die XRD: die Bestellkarte definieren
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: shopablagen.shop.werkzeugversand.example
spec:
scope: Namespaced
group: shop.werkzeugversand.example
names:
kind: ShopAblage
plural: shopablagen
versions:
- name: v1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
region:
description: Region, in der die Ablage entsteht.
type: string
aufbewahrungTage:
description: Tage, die eine alte Bildversion aufbewahrt wird.
type: integer
default: 30
required:
- region
status:
type: object
properties:
ablageName:
description: Name der Ablage beim Anbieter.
type: stringDas Schema ist ein gewöhnliches OpenAPI-Schema, dieselbe Sprache, in der auch die eingebauten Kubernetes-Typen beschrieben sind. Damit bekommst du die Validierung geschenkt: Wer aufbewahrungTage: dreißig schreibt, wird schon beim Anwenden abgewiesen und nicht erst, wenn eine API des Anbieters sich beschwert.
Das entscheidende Feld ist scope: Namespaced. Es ist in Version 2 die Voreinstellung und sorgt dafür, dass die Bestellkarte in einem Namespace liegt und nur Ressourcen im selben Namespace erzeugt. Die Trennung zwischen den Teams hängt damit nicht an einer Absprache, sie steckt im Objekt.
Anwenden und prüfen, ob Crossplane den neuen Typ angenommen hat:
kubectl apply -f xrd.yaml
kubectl get xrdNAME ESTABLISHED OFFERED AGE
shopablagen.shop.werkzeugversand.example True 6sESTABLISHED auf True heißt, dass der Cluster ab jetzt ShopAblage kennt, so wie er Deployment kennt.
Die Composition: was hinter der Bestellkarte passiert
Die Composition ist die Vorlage. Sie sagt, welche verwalteten Ressourcen entstehen, wenn jemand eine ShopAblage bestellt.
Seit Version 2 ist eine Composition eine Reihe von Funktionen, die nacheinander laufen und dabei die Liste der zu erzeugenden Ressourcen aufbauen. Das frühere Verfahren, bei dem die Composition die Felder direkt selbst zusammensetzte, ist seit 1.17 abgekündigt. Die gebräuchlichste Funktion für den Einstieg ist function-patch-and-transform, und sie muss wie ein Provider installiert werden:
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-patch-and-transform
spec:
package: xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.8.2Damit steht die Vorlage:
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: shopablage-s3
spec:
compositeTypeRef:
apiVersion: shop.werkzeugversand.example/v1
kind: ShopAblage
mode: Pipeline
pipeline:
- step: ressourcen-bauen
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: ablage
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: Bucket
spec:
forProvider: {}
providerConfigRef:
kind: ProviderConfig
name: standard
- name: versionierung
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketVersioning
spec:
forProvider:
bucketSelector:
matchControllerRef: true
versioningConfiguration:
status: Enabled
providerConfigRef:
kind: ProviderConfig
name: standard
- name: riegel
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketPublicAccessBlock
spec:
forProvider:
bucketSelector:
matchControllerRef: true
blockPublicAcls: true
blockPublicPolicy: true
ignorePublicAcls: true
restrictPublicBuckets: true
providerConfigRef:
kind: ProviderConfig
name: standardEin Detail darin ist der eigentliche Trick. Bei der ersten Ressource stand noch bucketRef mit einem festen Namen, hier steht bucketSelector mit matchControllerRef: true. Ein fester Name geht nicht mehr, denn die Vorlage weiß nicht, wie die Ablage heißen wird. Der Selektor sagt stattdessen: Nimm die Ablage, die zur selben Bestellung gehört wie ich. Damit funktioniert dieselbe Vorlage für jedes Team und jede Bestellung.
Was noch fehlt, ist die Verbindung zwischen Bestellkarte und Ressourcen. Die Region steht bisher nirgends.
Patches: wie der Wunsch in die Ressourcen kommt
Ein Patch kopiert einen Wert aus der Bestellkarte in die erzeugte Ressource. Bei der Ablage sind das zwei: die Region und die Aufbewahrungsfrist, und die zweite bekommt unterwegs eine andere Form.
Der einfache Fall, direkt in den Eintrag der Ablage:
- name: ablage
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: Bucket
spec:
forProvider: {}
providerConfigRef:
kind: ProviderConfig
name: standard
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- type: ToCompositeFieldPath
fromFieldPath: metadata.annotations[crossplane.io/external-name]
toFieldPath: status.ablageNameDer erste Patch läuft von der Bestellkarte in die Ressource, der zweite zurück. Der Rückweg ist der, den Teams am meisten nutzen: Er trägt den Namen, unter dem die Ablage beim Anbieter tatsächlich existiert, in den Status der Bestellkarte ein. Ohne ihn müsste jedes Team die erzeugten Ressourcen durchsuchen, um zu erfahren, wie sein Ablageort heißt.
Der zweite Fall ist der interessantere, weil sich der Wert unterwegs ändern muss. Die Bestellkarte nennt eine Zahl von Tagen, die Aufräumregel des Anbieters erwartet eine verschachtelte Struktur:
- name: aufraeumen
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketLifecycleConfiguration
spec:
forProvider:
bucketSelector:
matchControllerRef: true
rule:
- id: alte-bildversionen
status: Enabled
noncurrentVersionExpiration:
- noncurrentDays: 30
providerConfigRef:
kind: ProviderConfig
name: standard
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- type: FromCompositeFieldPath
fromFieldPath: spec.aufbewahrungTage
toFieldPath: spec.forProvider.rule[0].noncurrentVersionExpiration[0].noncurrentDaysDer Pfad mit den Indizes sieht sperrig aus und ist trotzdem die Stelle, an der die Bestellkarte ihren Wert zeigt. Aus einer Zahl, die ein Team versteht, wird hier die Struktur, die der Anbieter verlangt. Diese Übersetzung ist der eigentliche Inhalt einer Plattform-API, und sie muss jemand pflegen.
Denselben Region-Patch brauchen auch Versionierung, Riegel und Aufräumregel, denn region ist bei jeder dieser Ressourcen ein Pflichtfeld. In den vollständigen Manifesten am Ende ist er deshalb überall eingetragen.
Der Entwickler bestellt sich seine Ablage
Alles steht. Jetzt die Sicht, für die das Ganze gebaut wurde:
apiVersion: shop.werkzeugversand.example/v1
kind: ShopAblage
metadata:
name: anleitungen
namespace: team-ersatzteile
spec:
region: eu-central-1
aufbewahrungTage: 30kubectl apply -f ablage.yaml
kubectl get shopablage -n team-ersatzteileNAME SYNCED READY COMPOSITION AGE
anleitungen True True shopablage-s3 41sUnd darunter, was daraus entstanden ist:
kubectl get managed -n team-ersatzteileNAME SYNCED READY AGE
bucket/anleitungen-x7k2m True True 41s
bucketversioning/anleitungen-p4n8w True True 38s
bucketpublicaccessblock/anleitungen-d9 True True 38s
bucketlifecycleconfiguration/anlei-q3 True True 37sVier Ressourcen aus acht Zeilen, und die drei Regeln, die vorher jemand von Hand mitdenken musste, sind nicht mehr optional. Wer eine ShopAblage bestellt, bekommt Versionierung, Riegel und Aufräumregel, ob er die Begriffe kennt oder nicht.
Der Weg zurück ist genauso kurz. Wird die Bestellkarte gelöscht, verschwinden alle vier Ressourcen, weil sie ihr gehören.
Namespaces statt Claims: was Version 2 geändert hat
Wenn du älteren Anleitungen folgst, wirst du an dieser Stelle über einen Begriff stolpern, der hier nicht vorkommt: den Claim. Es lohnt sich, ihn einmal einzuordnen, weil die meisten Beispiele im Netz noch davon ausgehen.
Im alten Modell lagen Composite Resources im Cluster, ohne Namespace. Damit ein Team trotzdem etwas bestellen konnte, gab es ein zweites Objekt im Namespace, den Claim, der auf die eigentliche Ressource verwies. Zwei Objekte für eine Sache, nur damit die Trennung zwischen Teams funktionierte.
In Version 2 liegen Composite Resources selbst im Namespace, und damit fällt der Umweg weg. Die neuen Modi Namespaced und Cluster kennen keine Claims mehr. Wer sie noch braucht, setzt scope: LegacyCluster, das ist ausdrücklich der Rückwärts-Kompatibilitätsmodus.
Dasselbe gilt für die verwalteten Ressourcen. Auch sie sind in Version 2 im Namespace zuhause, erkennbar am m in der API-Gruppe. Die alte, clusterweite Variante läuft weiter, gilt aber als Altlast und soll später entfernt werden. Für einen neuen Aufbau gibt es also keinen Grund, mit dem alten Modell anzufangen.
Praktisch heißt das beim Lesen fremder Beispiele: Steht dort kind: XPostgreSQLInstance neben einem PostgreSQLInstance-Claim, ist es das alte Modell. Steht dort apiextensions.crossplane.io/v2 und scope: Namespaced, ist es das neue.
Status zurückgeben: was der Entwickler sehen soll
Eine Plattform-API, die nur entgegennimmt und nichts zurückgibt, ist nur eine halbe API. Das Team der Ersatzteilsuche muss erfahren, wie seine Ablage heißt, sonst kann es sie nicht in die Anwendung eintragen.
Den Rückweg macht der Patch mit ToCompositeFieldPath aus dem vorigen Abschnitt. Er füllt das Feld, das in der XRD unter status deklariert ist:
kubectl get shopablage anleitungen -n team-ersatzteile -o jsonpath='{.status.ablageName}'anleitungen-x7k2mDer Name trägt ein Zufallssuffix, und das ist Absicht. Namen von Objektspeichern sind bei vielen Anbietern global eindeutig, ein fester Name würde beim zweiten Team kollidieren. Crossplane erzeugt deshalb einen eindeutigen Namen und schreibt ihn in die Annotation crossplane.io/external-name. Wer einen festen Namen braucht, setzt genau diese Annotation selbst.
Für Zugangsdaten gibt es einen eigenen Weg. Verbindungsdetails einer Ressource, etwa Benutzer und Passwort einer Datenbank, landen nicht im Status, sondern in einem Secret. Der Status ist für jeden lesbar, der die Bestellkarte sehen darf, ein Geheimnis hat dort nichts zu suchen.
Löschen: die Regel, die einmal wehtut
Das Löschen ist der Teil, den Anleitungen gern auslassen, und der in der Praxis am meisten Schaden anrichtet.
Die Grundregel ist einfach: Wird die Bestellkarte gelöscht, werden auch die erzeugten Ressourcen gelöscht, und mit ihnen die Objekte beim Anbieter. Ein Ablageort mit Montageanleitungen ist dann weg, samt Inhalt.
Das ist meistens richtig und manchmal fatal. Deshalb hat jede verwaltete Ressource ein Feld dafür:
spec:
deletionPolicy: OrphanMit Orphan verschwindet nur das Kubernetes-Objekt, das Objekt beim Anbieter bleibt stehen. Die Voreinstellung ist Delete. Für alles, was Daten hält, ist Orphan in der Vorlage die vorsichtigere Wahl, denn ein versehentlich gelöschter Ablageort lässt sich nicht durch ein erneutes Anwenden zurückholen.
Der zweite, unangenehmere Fall ist der, bei dem gar nichts mehr passiert. Bleibt ein Objekt beim Löschen hängen, liegt es fast immer an einem Finalizer: Crossplane hat den Löschauftrag beim Anbieter abgesetzt, bekommt aber keine Bestätigung, weil die Rechte fehlen oder die Ressource noch belegt ist. Ein Ablageort mit Inhalt lässt sich bei den meisten Anbietern nicht löschen, solange Dateien darin liegen. Das Objekt steht dann auf Terminating, und der Grund steht in den Events:
kubectl describe bucket anleitungen-x7k2m -n team-ersatzteileDen Finalizer von Hand zu entfernen, löst das Symptom und hinterlässt die Ressource beim Anbieter, ohne dass noch jemand von ihr weiß. Es ist die Notbremse, nicht die Lösung.
Die vollständigen Manifeste
Alles zusammen, in der Reihenfolge, in der es angewendet wird. Provider, Function und ProviderConfig aus den vorigen Abschnitten sind vorausgesetzt.
Die Bestellkarte:
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: shopablagen.shop.werkzeugversand.example
spec:
scope: Namespaced
group: shop.werkzeugversand.example
names:
kind: ShopAblage
plural: shopablagen
versions:
- name: v1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
region:
description: Region, in der die Ablage entsteht.
type: string
aufbewahrungTage:
description: Tage, die eine alte Bildversion aufbewahrt wird.
type: integer
default: 30
required:
- region
status:
type: object
properties:
ablageName:
description: Name der Ablage beim Anbieter.
type: stringDie Vorlage:
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: shopablage-s3
spec:
compositeTypeRef:
apiVersion: shop.werkzeugversand.example/v1
kind: ShopAblage
mode: Pipeline
pipeline:
- step: ressourcen-bauen
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: ablage
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: Bucket
spec:
forProvider: {}
deletionPolicy: Orphan
providerConfigRef:
kind: ProviderConfig
name: standard
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- type: ToCompositeFieldPath
fromFieldPath: metadata.annotations[crossplane.io/external-name]
toFieldPath: status.ablageName
- name: versionierung
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketVersioning
spec:
forProvider:
bucketSelector:
matchControllerRef: true
versioningConfiguration:
status: Enabled
providerConfigRef:
kind: ProviderConfig
name: standard
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- name: riegel
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketPublicAccessBlock
spec:
forProvider:
bucketSelector:
matchControllerRef: true
blockPublicAcls: true
blockPublicPolicy: true
ignorePublicAcls: true
restrictPublicBuckets: true
providerConfigRef:
kind: ProviderConfig
name: standard
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- name: aufraeumen
base:
apiVersion: s3.aws.m.upbound.io/v1beta1
kind: BucketLifecycleConfiguration
spec:
forProvider:
bucketSelector:
matchControllerRef: true
rule:
- id: alte-bildversionen
status: Enabled
noncurrentVersionExpiration:
- noncurrentDays: 30
providerConfigRef:
kind: ProviderConfig
name: standard
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- type: FromCompositeFieldPath
fromFieldPath: spec.aufbewahrungTage
toFieldPath: spec.forProvider.rule[0].noncurrentVersionExpiration[0].noncurrentDaysDie Bestellung:
apiVersion: shop.werkzeugversand.example/v1
kind: ShopAblage
metadata:
name: anleitungen
namespace: team-ersatzteile
spec:
region: eu-central-1
aufbewahrungTage: 30Die Bausteine auf einen Blick
| Baustein | API-Gruppe | Wo er lebt | Wofür |
|---|---|---|---|
| Provider | pkg.crossplane.io/v1 |
clusterweit | bringt die Ressourcentypen eines Dienstes mit |
| Function | pkg.crossplane.io/v1 |
clusterweit | verarbeitet die Vorlage einer Composition |
| ProviderConfig | <anbieter>.m.upbound.io/v1beta1 |
Namespace | Zugangsdaten für Ressourcen im selben Namespace |
| ClusterProviderConfig | <anbieter>.m.upbound.io/v1beta1 |
clusterweit | Zugangsdaten für alle Namespaces |
| Managed Resource | <dienst>.<anbieter>.m.upbound.io/v1beta1 |
Namespace | ein einzelnes Objekt beim Anbieter |
| CompositeResourceDefinition | apiextensions.crossplane.io/v2 |
clusterweit | definiert den eigenen Typ und sein Schema |
| Composition | apiextensions.crossplane.io/v1 |
clusterweit | Vorlage, was auf eine Bestellung hin entsteht |
| Composite Resource | die eigene Gruppe | Namespace | die Bestellung selbst |
Die gemischten Versionen in der Tabelle sind kein Tippfehler. Die XRD-API ist mit Version 2 auf v2 gesprungen, weil dort das Feld scope hinzukam, die Composition ist bei v1 geblieben.
Zwei Abkürzungen begegnen dir dabei ständig: XRD für CompositeResourceDefinition und XR für Composite Resource.
Crossplane oder ein Infrastruktur-Lauf?
Die Frage wird meistens als Werkzeugvergleich gestellt und ist keiner. Beide beschreiben Infrastruktur deklarativ, beide sind quelloffen, beide können dieselben Ressourcen anlegen. Der Unterschied liegt im Betriebsmodell und in der Frage, wer bestellt.
Ein Lauf hat einen klaren Anfang und ein klares Ende. Das macht ihn nachvollziehbar, weil ein Mensch den Plan vor der Ausführung liest und danach ein Protokoll dasteht. Für alles, was selten passiert und gut überlegt sein will, ist das die passende Form. Ein Netzwerk, ein Cluster, die Grundlage eben.
Beim Controller fehlt dieser Moment. Niemand liest vorher einen Plan, dafür trägt er alles, was oft passiert und immer gleich aussehen soll. Die Ablage aus diesem Artikel ist dieser Fall: immer dasselbe, bestellt von wechselnden Leuten aus sechs Teams.
Die verbreitete Aufteilung folgt deshalb aus den beiden Modellen. Der Lauf legt das Fundament, Crossplane liefert darauf das, was Teams sich selbst bestellen. Wer beides mischt, sollte die Grenze bewusst ziehen und aufschreiben, denn zwei Werkzeuge auf derselben Ressource enden in einem Konflikt, den niemand mehr auflöst.
Häufige Fallstricke
- Der monolithische Provider eines großen Anbieters installiert über 900 Ressourcentypen und kann den API-Server für längere Zeit blockieren. Installiere ein Paket je Dienst statt des Sammelpakets.
- Fehlt in der API-Gruppe das
m, oder taucht ein Claim auf, dann beschreibt die Anleitung Crossplane 1. Vieles davon läuft weiter, aber für einen neuen Aufbau baust du damit von vornherein Altlast ein. - Ein fester Name in
bucketReffunktioniert in einer Vorlage nicht, weil die Namen erst bei der Bestellung entstehen. Der Selektor mitmatchControllerRef: truenimmt die Geschwister-Ressource derselben Bestellung. - Eine
ProviderConfiggilt nur im eigenen Namespace. Fehlt sie dort, bleibt die Ressource aufSYNCED: Falsestehen, obwohl in einem anderen Namespace eine gleichnamige existiert. Für konto-übergreifende Nutzung istClusterProviderConfiggedacht. SYNCEDheißt, der Wunsch ist beim Anbieter angekommen,READYheißt, die Ressource ist benutzbar. Automatisierung, die aufSYNCEDwartet, startet zu früh.- Der Einstieg über ein Secret mit Zugangsdaten ist bequem und dauerhaft. Beim Umstieg auf Workload Identity vergisst man leicht das Aufräumen: Die alten Schlüssel müssen beim Anbieter zurückgezogen werden, sonst funktionieren sie weiter.
deletionPolicysteht aufDelete, wenn nichts anderes dasteht. Bei allem, was Daten hält, gehörtOrphanin die Vorlage.- Crossplane zieht nur nach, was es selbst verwaltet, im Takt des Poll-Intervalls, und nur bei Feldern, die im Manifest stehen. Von Hand angelegte Ressourcen und nicht gesetzte Felder bleiben unberührt.
Wann sich Crossplane lohnt, wann nicht
Es lohnt sich, wenn dieselbe Sorte Infrastruktur immer wieder bestellt wird und die Bestellenden nicht die Fachleute dafür sind. Der Werkzeug-Shop mit sechs Teams, die alle dieselbe Ablage brauchen, ist dieser Fall. Der Aufwand für XRD und Composition zahlt sich ab der dritten oder vierten Bestellung aus, davor ist er reiner Mehraufwand.
Dazu kommt der Fall, dass Kubernetes ohnehin schon der Ort ist, an dem betrieben wird. Dann ist der Cluster keine zusätzliche Baustelle, und RBAC, GitOps und Monitoring gelten für die Infrastruktur mit.
Bei einmaligen Aufbauten trägt das Modell nicht. Ein Netzwerk, das einmal entsteht und dann zehn Jahre steht, gewinnt durch einen dauernden Abgleich wenig und verliert den lesbaren Plan vor der Ausführung.
Ebenso wenig trägt es, wenn niemand den Cluster betreiben will. Crossplane setzt einen Cluster voraus, der selbst Produktionsinfrastruktur ist, mit Updates, Sicherung und Bereitschaft. Wer diesen Cluster nur für Crossplane aufbauen müsste, kauft sich Betrieb ein, um Betrieb zu sparen.
Und gegen den Willen der Teams lohnt es sich nie. Eine Plattform-API, die niemand bestellt, ist teurer als das Ticket, das sie ersetzen sollte.
FAQ
Ersetzt Crossplane ein klassisches Infrastruktur-Werkzeug?
In der Regel nicht. Das verbreitete Muster ist eine Aufteilung: Der Lauf legt das Fundament wie Netzwerk und Cluster, Crossplane liefert darauf die Ressourcen, die Teams sich selbst bestellen. Beides auf derselben Ressource zu betreiben, führt zu Konflikten.
Was passiert, wenn jemand eine Ressource von Hand ändert?
Verwaltet Crossplane diese Ressource und ist das geänderte Feld im Manifest gesetzt, wird die Änderung beim nächsten Abgleich zurückgenommen. Der Crossplane-Kern prüft standardmäßig jede Minute, aus Terraform-Providern erzeugte Pakete prüfen pro Ressource standardmäßig alle zehn Minuten.
Braucht jedes Team einen eigenen Cluster?
Nein, das ist der Zweck der Namespaces in Version 2. Composite und Managed Resources liegen im Namespace, die Zugangsdaten über die ProviderConfig ebenfalls, und die Trennung übernimmt RBAC.
Was ist der Unterschied zwischen XRD und Composition?
Die XRD definiert den Typ und sein Schema, also was bestellt werden kann. Die Composition definiert, was auf eine Bestellung hin entsteht. Zu einer XRD kann es mehrere Compositions geben, etwa eine je Cloud.
Gibt es Crossplane noch mit Claims?
Die neuen Modi Namespaced und Cluster kennen keine Claims. Wer sie braucht, setzt scope: LegacyCluster, den Rückwärts-Kompatibilitätsmodus. Für einen neuen Aufbau gibt es dafür keinen Grund.
Wie viele CustomResourceDefinitions installiert ein Provider?
Beim monolithischen Paket eines großen Anbieters sind es über 900. Provider Families lösen das, indem du nur das Paket je Dienst installierst; für den Objektspeicher sind es dann gut zwei Dutzend Typen.
Was ist mit wiederkehrenden Aufgaben, die keine Ressource anlegen?
Dafür gibt es seit Version 2 den Typ Operation, der eine Funktions-Pipeline einmalig, geplant oder auf ein Ereignis hin bis zum Ende ausführt, ähnlich einem Job. Er ist als Alpha gekennzeichnet, also noch nicht für Produktion gedacht.
Fazit
Crossplane verschiebt die Frage. Sie lautet nicht mehr, wie eine Ablage angelegt wird, sondern wer sich eine bestellen darf und was dabei automatisch gilt. Der Werkzeug-Shop aus dem Anfang hat nicht bloß ein Ticket eingespart, er hat zwei Fehlerklassen ausgeschlossen: das Kopieren mit Auslassung, weil es nichts mehr zu kopieren gibt, und den unbemerkten Handgriff, weil der Controller ihn zurücknimmt.
Bezahlt wird das mit einem Cluster, der zur Produktionsinfrastruktur wird, und mit einer Plattform-API, die jemandem gehören muss. Beides ist beherrschbar, aber beides ist echte Arbeit und sollte vor der ersten XRD entschieden sein.
Der nächste sinnvolle Schritt ist klein: Nimm einen lokalen Cluster, installiere das Paket für einen einzigen Dienst und lege eine einzige verwaltete Ressource an. Dann ändere sie von Hand beim Anbieter und warte den Abgleich ab. Dieser eine Versuch erklärt das Modell besser als jede Beschreibung.
Quellen
- Crossplane-Dokumentation, Abschnitte „What’s New in v2“, „Composite Resource Definitions“, „Compositions“, „Managed Resources“, „Providers“ und „Get Started With Composition“, Stand Version 2.3
- Ankündigung von Crossplane 2.0 im Crossplane-Blog
- Crossplane-Blog zum Wachstum der CustomResourceDefinitions und zu Provider Families
- Ankündigung der CNCF zur Graduation von Crossplane
- Referenz des AWS-S3-Providers im Upbound Marketplace für die Feldnamen der Ressourcen
Alle Manifeste und das Beispiel des Werkzeug-Shops sind eigene und gegen Crossplane 2.3 geschrieben. Die Ausgaben der Kommandozeile sind gekürzt und in den Namen angepasst.