CoursesCrossplaneComposite resources & compositions

Composite resources & compositions

Bundle many resources into one API.

Intermediate14 min · lesson 5 of 12

Order a combo meal and you say one thing, "number three," and the kitchen assembles a burger, fries, and a drink out of sight. You didn't order three items. You ordered one, and the bundle knew what it held.

A Crossplane Composite Resource, or XR (a single custom Kubernetes object that stands in for a whole group of real cloud resources), is that combo. Crossplane itself is an add-on that turns a Kubernetes cluster into a control plane for cloud infrastructure. The previous lesson showed the Managed Resource, or MR (a Kubernetes object that maps one-to-one onto a single real cloud resource, like one database or one bucket). Real platforms are never one resource, though. A usable database wants storage, a subnet group (which network segments it lives in), a parameter group, and access rules, and all of those have to agree. Hand every developer those four pieces to wire up by hand and you have given each of them four chances to misconfigure production. Give them one XR instead, and a recipe fans it out into the real resources underneath.

One object that stands for many

An XR is a custom Kubernetes object whose entire job is to represent a bundle. You apply one XR. Crossplane creates and owns the many managed resources it composes. The XR carries the caller's intent in spec.parameters, a small set of knobs, and nothing else. It does not know it is AWS. It does not know what a subnet group is. That knowledge lives in the recipe, not in the request.

The type XPostgresInstance does not exist until you register it with a Composite Resource Definition, or XRD (the object that publishes your new API and the fields it allows). That is the next lesson. For now, assume the type is registered and look at what one instance costs the person asking for it: almost nothing.

orders-db.yaml
# XPostgresInstance is a type your XRD publishes (next lesson).
apiVersion: platform.example.org/v1alpha1
kind: XPostgresInstance
metadata:
name: orders-db
spec:
parameters:
storageGB: 50 # a knob the caller turns
region: us-east-1 # another one
compositionRef:
name: xpostgres.aws.platform.example.org # which recipe to use

That is the whole request. Two parameters and a pointer at a recipe. Everything hard is about to happen somewhere the caller never sees.

The Composition is the recipe

A Composition is a cluster-scoped object (it lives at the level of the whole cluster, not inside one namespace) that answers two questions. Which XR type does it serve? That is compositeTypeRef. And what real resources should it render? Modern Crossplane answers the second question by running a pipeline of functions rather than a fixed list, the same way a kitchen runs a line of stations instead of one cook doing everything.

The workhorse function for plain mapping is function-patch-and-transform (a Crossplane Function package you install once). It takes a base template for each managed resource, then patches values from the XR onto that base at render time. The base is the MR exactly as the earlier lesson taught it. The patches copy fields off the composite and drop them into the base. Loops, conditionals, and generating a variable number of resources are what full composition functions are for, and that is a later lesson. Here you template each MR and wire the caller's parameters in.

composition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xpostgres.aws.platform.example.org
spec:
compositeTypeRef:
apiVersion: platform.example.org/v1alpha1
kind: XPostgresInstance
mode: Pipeline
pipeline:
- step: patch-and-transform
functionRef:
name: function-patch-and-transform # installed as a Function package
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: rds
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
spec:
forProvider:
engine: postgres
instanceClass: db.t3.micro
allocatedStorage: 20 # overwritten by the patch below
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.storageGB
toFieldPath: spec.forProvider.allocatedStorage
policy:
fromFieldPath: Required # block this MR if the field is missing
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.region
toFieldPath: spec.forProvider.region
- name: subnet-group
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: SubnetGroup
spec:
forProvider:
region: us-east-1
description: managed by crossplane
# subnetIds / subnetIdSelector trimmed for brevity

Read the rds resource top to bottom. The base says every database this recipe makes is Postgres on a db.t3.micro with 20 GB. The first patch then overwrites that 20 with whatever the caller put in storageGB, so orders-db comes up at 50. The second patch copies the region across. FromCompositeFieldPath means read from the XR, write onto the child. The policy.fromFieldPath: Required line on the storage patch is the interesting one, and we come back to why it matters.

Watching one become many

Apply the XR:

terminal
kubectl apply -f orders-db.yaml
output
xpostgresinstance.platform.example.org/orders-db created

The interesting part is what you did not have to write. That one object turns into a tree, and the cleanest way to see the tree is the Crossplane command-line tool.

terminal
crossplane resource trace xpostgresinstance orders-db
output
NAME SYNCED READY STATUS
XPostgresInstance/orders-db True True Available
├─ Instance/orders-db-x7fq2 True True Available
└─ SubnetGroup/orders-db-9dk4m True True Available

One XR, two managed resources beneath it, each with its own generated name. SYNCED means Crossplane reconciled the object with the provider. READY means the real cloud resource reports itself usable. You can get the same children with plain kubectl if you do not have the crossplane CLI installed.

terminal
kubectl get managed
output
NAME SYNCED READY EXTERNAL-NAME AGE
instance.rds.aws.upbound.io/orders-db-x7fq2 True True orders-db-x7fq2 4m
subnetgroup.rds.aws.upbound.io/orders-db-9dk4m True True orders-db-9dk4m 4m

For an operator this listing is a live inventory. Every real cloud resource Crossplane owns shows up here, with a column telling you whether it is in sync. A row stuck at SYNCED=False means the last reconcile failed: wrong or expired credentials, a missing IAM permission (Identity and Access Management, the cloud's own access-control system), or a field the cloud API rejected. A managed resource that shows up with no XR above it is worth a hard look, because someone created cloud infrastructure outside the recipe, which is exactly the blind spot a Composition is supposed to remove.

How one XR becomes many resources
1You apply one XR
spec.parameters carry intent
2Crossplane picks a Composition
by compositionRef or label selector
3It binds to a CompositionRevision
an immutable snapshot it renders against
4The function pipeline renders
base plus patches per managed resource
5The provider reconciles each MR
real cloud resources get created
6The XR owns every child
delete the XR, all of them cascade

One owner, one delete

Here is the part that matters at 2 a.m. Every managed resource the recipe made carries an owner reference (a small pointer in its metadata that says this object belongs to that one). The owner is the XR. Look at one child:

terminal
kubectl get instance.rds.aws.upbound.io orders-db-x7fq2 \
-o jsonpath='{range .metadata.ownerReferences[*]}{.kind}{" "}{.name}{"\n"}{end}'
output
XPostgresInstance orders-db

Kubernetes uses that pointer for garbage collection (its built-in cleanup of objects whose owner is gone). Delete the one XR and every child it owns is deleted with it. No orphaned database quietly billing you for a year. No subnet group nobody remembers creating. The blast radius of a cleanup is exactly the bundle, and it is exactly the bundle you can see.

That same owner reference is your audit trail. Ask any cloud resource in the cluster what created it and the answer is the XR, and the XR spec shows exactly which parameters were requested. Pair that with the cluster audit log (Kubernetes' record of who changed what, where it is enabled) and you can walk a resource back to the person who applied it. When an attacker or a careless script spins something up, the first question a defender asks is what owns this, and was it supposed to exist. The owner reference answers the first half in one field.

Choosing the recipe, and rolling it out

Many recipes can serve the same XR type. An AWS recipe, a GCP recipe, a cheap-dev recipe that skips the expensive parts. The XR picks in one of two ways. Pin one by name with compositionRef, which is what the first example did. Or describe the recipe you want with compositionSelector and let Crossplane match by labels.

selection.yaml
spec:
# Option A: match by labels instead of a hard-coded name
compositionSelector:
matchLabels:
provider: aws
tier: standard
# Option B: pin a specific frozen revision, update on your terms
compositionUpdatePolicy: Manual # default is Automatic
compositionRevisionRef:
name: xpostgres.aws.platform.example.org-a1b2c3d

Behind the scenes Crossplane never lets an XR point straight at a mutable Composition. Every time you save a Composition, Crossplane freezes a copy as a CompositionRevision (an immutable, numbered snapshot), the way a version history keeps every past draft of a document. The XR runs against a revision, not the live object. Edit the recipe and you get a new revision beside the old one:

terminal
kubectl get compositionrevision
output
NAME REVISION XR-KIND XR-APIVERSION AGE
xpostgres.aws.platform.example.org-a1b2c3d 1 XPostgresInstance platform.example.org/v1alpha1 18m
xpostgres.aws.platform.example.org-e4f5a6b 2 XPostgresInstance platform.example.org/v1alpha1 90s

compositionUpdatePolicy decides what an XR does with that new revision 2. The default, Automatic, moves every XR onto the newest revision as soon as it exists. Set it to Manual with a compositionRevisionRef and the XR stays pinned until you move it by hand. That is how you promote a recipe change to one database first, watch it, and only then let the fleet follow. It is the platform version of a canary release (ship to one, prove it, then ship to all).

A Composition edit ships to every consumer at once
The default compositionUpdatePolicy is Automatic, so every XR tracks the newest CompositionRevision. A one-character mistake in a shared Composition (a wrong toFieldPath, a typo'd region, a bad instance class) reconciles into every resource that uses that recipe within seconds, and can degrade dozens of production databases at the same time. Treat editing a Composition like a deploy, not a config tweak: review it, roll it out on Manual where you cannot afford a bulk failure, and keep the previous revision so you can pin back to it.

Why Required on a patch is a safety line

Go back to policy.fromFieldPath: Required on the storage patch. The default for a patch is Optional, and Optional is quietly dangerous. If the source path is wrong or missing, an Optional patch does nothing at all. The child resource comes up anyway, using the base default. So a database you asked to be 50 GB comes up at 20, nothing errors, and you find out from a bill or an outage. Required flips that. If the field is missing, Crossplane fails the render and reports it, so the mistake surfaces at apply time instead of in production. Put Required on any patch whose value you cannot afford to have silently skipped.

Quick check
01You edit a shared Composition to fix one field, save it, and within a minute several unrelated production databases start showing SYNCED=False. What most likely happened, and what would have prevented it?
Correct — Automatic is the default, so one edit reconciles into every consumer at once; Manual pins an XR to a chosen revision until you promote it.
Incorrect — Owner references drive cascade delete when the XR is deleted, not a failed sync after a Composition edit.
Incorrect — Revisions are immutable snapshots; the edit created a new numbered revision, it did not rewrite the old one.
Incorrect — Compositions are cluster-scoped; the blast radius comes from the Automatic update policy, not namespace leakage.
02In a function-patch-and-transform Composition, a patch copies spec.parameters.storageGB onto the child database's storage and is marked policy.fromFieldPath: Required. The default patch policy is Optional. Why does Required matter here?
Incorrect — patches are not applied twice; the real difference is what happens when the source field is missing.
Incorrect — Required does not retry; it fails the render immediately when the field is absent.
Correct — Optional hides a broken patch behind the template default, while Required turns it into a visible failure you catch on apply.
Incorrect — the policy governs patch behavior on missing fields, not deletion order.
03A composite resource (XR) named orders-db rendered two managed resources, an RDS (Relational Database Service) Instance and a SubnetGroup, each with a generated name. A developer no longer needs it and runs kubectl delete xpostgresinstance orders-db. With default settings, what happens to the two children?
Correct — the owner reference is what makes deleting the one XR cascade to every child it composed.
Incorrect — a generated name does not sever the owner reference that drives garbage collection.
Incorrect — both children carry owner references, so both are collected.
Incorrect — garbage collection removes owned children automatically once the XR is gone.

Before you touch a Composition in a live cluster, run kubectl get xpostgresinstance and read the COMPOSITION column. Every row pointing at the recipe you are about to change is a real resource your next save reconciles into. If that is more than one and you are not certain of the edit, move the ones you cannot afford to break onto compositionUpdatePolicy: Manual first, then save.

Try this

Run kubectl apply -f orders-db.yaml on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a Composition edit ships to every consumer at once. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related