The Reconcile Loop
This chapter explains how the Reconcile loop works in multicluster-runtime.
If you already know controller-runtime, you can think of this as the familiar
Reconcile(ctx, req) pattern, extended with a cluster dimension so that the
same reconciler can safely act across a fleet of clusters.
We will look at:
- how a multi-cluster request (
mcreconcile.Request) differs fromreconcile.Request, - where the
ClusterNamecomes from, - how to write Reconcilers that resolve the right cluster client,
- how to adapt existing single-cluster Reconcilers,
- and how errors and retries behave when clusters appear and disappear.
From single-cluster to multi-cluster Reconcile
In a single-cluster controller-runtime application, a Reconciler typically looks like:
func (r *MyReconciler) Reconcile(ctx context.Context, req reconcile.Request) (ctrl.Result, error) {
// Implicitly talks to “the” cluster via r.Client or mgr.GetClient().
var obj myv1alpha1.MyResource
if err := r.Client.Get(ctx, req.NamespacedName, &obj); err != nil {
// ...
}
// business logic...
return ctrl.Result{}, nil
}Key properties:
- The request key (
NamespacedName) is relative to a single cluster. - The Reconciler typically uses a single client and cache, bound to one API server.
With multicluster-runtime, the main change is that every work item becomes cluster‑qualified:
- the request now carries a
ClusterNamein addition to the object key, and - the Reconciler is expected to use the correct per-cluster client based on that name.
The rest of the Reconcile loop—queueing, retries, result semantics—remains the same as in controller-runtime.
The multi-cluster Request type
The core request type for multi-cluster controllers is mcreconcile.Request (pkg/reconcile/request.go):
- Fields
ClusterName string
The logical name of the cluster this work item belongs to. The empty string ("") is reserved for the local (host) cluster.Request reconcile.Request
The usual controller-runtime request containingNamespacedNamefor the object within that cluster.
Conceptually:
ClusterNameselects which cluster to talk to.Request.NamespacedNameselects which object to talk to in that cluster.
For convenience, the package also defines:
type Reconciler = reconcile.TypedReconciler[mcreconcile.Request]
A reconciler type alias for the multi-cluster request.type Func = reconcile.TypedFunc[mcreconcile.Request]
A helper to create Reconcilers from plain functions, often used with the Builder:
err := mcbuilder.ControllerManagedBy(mgr).
Named("multicluster-configmaps").
For(&corev1.ConfigMap{}).
Complete(mcreconcile.Func(
func(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) {
// ...
return ctrl.Result{}, nil
},
))Under the hood, mcreconcile.Request also implements a string representation that
includes the cluster when present (e.g. cluster://cluster-a/ns/name), which simplifies logging and debugging.
Cluster-aware generic requests
Internally, multi-cluster controllers use a generic interface:
ClusterAware[request]
A constraint for request types that:- are comparable,
- implement
fmt.Stringer, - and expose
Cluster()/WithCluster(string)methods.
mcreconcile.Request implements this interface, but you can also build your own
cluster-aware request type with the WithCluster[request] helper if you need a
custom key shape. For most controllers, sticking with mcreconcile.Request is sufficient.
Where ClusterName comes from
ClusterName is populated before your Reconciler runs. The flow is:
- A Provider discovers or constructs
cluster.Clusterobjects for each member cluster and engages them with the Multi-Cluster Manager. - For each engaged cluster, multi-cluster Sources (for example,
mcsource.Kind) attach event handlers to that cluster’s cache and informers. - When an event occurs (Create / Update / Delete):
- the cluster-specific Source builds a
requestvalue that implementsClusterAware, - it sets
ClusterNameto the name of the cluster, - it sets the inner
Request(formcreconcile.Request) or wraps some other key, - and it enqueues that work item on the controller’s shared workqueue.
- the cluster-specific Source builds a
- The controller’s workers pop items off the queue and call your Reconciler with the fully-populated
mcreconcile.Request.
The controller type (pkg/controller/controller.go) is itself multi-cluster–aware:
- it implements
multicluster.Awareso that the Manager or Provider can callEngage(ctx, name, cluster)when a new cluster appears, - it records each engaged
{clusterName → cluster.Cluster}, - for each cluster, it wires the multi-cluster Sources (via
MultiClusterWatch) so they start injecting requests tagged with thatclusterName.
The result is:
- each engaged cluster has its own cache and informers,
- but all work items feed into a single logical queue,
- and every request knows which cluster it belongs to.
Writing a cluster-aware Reconciler
From a Reconciler author’s perspective, the core pattern is:
- Use
mcreconcile.Requestas the request type. - Resolve the correct
cluster.Clusterusing the Multi-Cluster Manager. - Use that cluster’s client and cache just like you would in a single-cluster controller.
For example, a uniform reconciler that logs ConfigMaps across all clusters:
func (r *ConfigMapReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) {
log := ctrllog.FromContext(ctx).WithValues("cluster", req.ClusterName)
log.Info("Reconciling ConfigMap")
// 1. Resolve the cluster for this request.
cl, err := r.Manager.GetCluster(ctx, req.ClusterName)
if err != nil {
return reconcile.Result{}, err
}
// 2. Use the per-cluster client to fetch the object.
cm := &corev1.ConfigMap{}
if err := cl.GetClient().Get(ctx, req.Request.NamespacedName, cm); err != nil {
if apierrors.IsNotFound(err) {
// Object was deleted; nothing to do.
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
// 3. Business logic (log, emit events, mutate state, etc.).
log.Info("ConfigMap found",
"namespace", cm.Namespace,
"name", cm.Name,
"cluster", req.ClusterName,
)
return ctrl.Result{}, nil
}Notes:
req.ClusterNametells you which cluster to target.req.Request.NamespacedNameis the familiar key inside that cluster.GetClustermay return an error if the cluster has been removed since the item was queued (see the next section).
The examples in the upstream repository (refs/multicluster-runtime/examples/*/main.go)
follow exactly this pattern for various providers (Kind, File, Kubeconfig, Cluster API, Cluster Inventory API, Namespace).
Handling disappearing clusters and ErrClusterNotFound
In a dynamic fleet, clusters can disappear while there are still work items in the queue.
When that happens, calling mgr.GetCluster(ctx, req.ClusterName) may fail with multicluster.ErrClusterNotFound.
By default, multicluster-runtime helps you treat this as a clean terminal condition:
- the Builder (
pkg/builder) wraps your Reconciler in aClusterNotFoundWrapperunless you opt out, - this wrapper:
- calls your Reconciler,
- checks the returned error,
- if the error is (or wraps)
multicluster.ErrClusterNotFound, it treats the reconcile as successful and does not requeue, - otherwise it propagates the original result and error.
This prevents controllers from endlessly retrying items for clusters that no longer exist.
If you want to handle this case yourself (for example, to record metrics or emit a specific Event), you can:
- disable the wrapper when building the controller:
_ = mcbuilder.ControllerManagedBy(mgr).
WithClusterNotFoundWrapper(false).
// ...
Complete(myReconciler)- and in your Reconciler, explicitly detect and handle
multicluster.ErrClusterNotFound.
Apart from this multi-cluster-specific helper, error handling and retry semantics are the same as in controller-runtime:
- returning an error causes a requeue using the controller’s rate-limiting queue,
- returning
ctrl.Result{RequeueAfter: ...}schedules a later retry, - returning
ctrl.Result{}withnilerror marks the work as successfully processed.
Using context for cluster awareness
Sometimes you want to adapt an existing Reconciler that still expects a single-cluster reconcile.Request and obtains its client from a Manager or global state.
multicluster-runtime provides a small bridge in pkg/context/cluster.go:
context.WithCluster(ctx, clusterName)/context.ClusterFrom(ctx)
Associate a cluster name with acontext.Contextand retrieve it later.context.ReconcilerWithClusterInContext(r reconcile.Reconciler) mcreconcile.Reconciler
Wrap a standardreconcile.Reconcilerso that:- it receives a multi-cluster
mcreconcile.Request, - the wrapper injects
req.ClusterNameinto the context usingWithCluster, - then calls the original Reconciler with the inner
req.Request.
- it receives a multi-cluster
On the Manager side, you can then use:
mgr.ClusterFromContext(ctx)
to resolve the currentcluster.Clusterbased on the cluster name stored in the context.
This pattern is useful when:
- incrementally migrating existing code to multi-cluster,
- or when you want to centralise the “resolve cluster from context” logic rather than passing
ClusterNamearound explicitly.
For new code, the recommended style is usually:
- accept
mcreconcile.Request, - call
mgr.GetCluster(ctx, req.ClusterName)directly, - and keep your business logic explicit about which cluster it is acting on.
Uniform vs multi-cluster-aware Reconcile loops
multicluster-runtime supports two main patterns for business logic:
-
Uniform Reconcilers
- The same logic runs independently in every cluster.
- The reconciler:
- reads resources in
req.ClusterName, - writes back only to that cluster.
- reads resources in
- Examples:
- enforcing the presence of standard
ConfigMaps, RBAC, or policies, - collecting per-cluster health or metrics.
- enforcing the presence of standard
-
Multi-cluster-aware Reconcilers
- The logic explicitly considers relationships between clusters.
- The reconciler may:
- read from one cluster (for example, a management cluster or inventory),
- write to one or more other clusters,
- or coordinate state across many clusters.
- Examples:
- Rolling out workloads across a ClusterSet based on capacity or location,
- propagating certificates from a central CA cluster to member clusters,
- aggregating state from many clusters into a single reporting target.
The Reconcile loop API is the same for both patterns; the difference is which clusters you read and write.
The “Controller Patterns” chapter dives deeper into these patterns and how to structure your code for each.
How the Builder wires the Reconcile loop
The mcbuilder package (pkg/builder) is the primary entry point for registering multi-cluster controllers. It is a thin adaptation of controller-runtime’s builder that:
- uses
mcreconcile.Request(or otherClusterAwarerequest types), - wires multi-cluster
Kindsources and other Sources against all engaged clusters, - applies
EngageOptions(such asWithEngageWithLocalClusterandWithEngageWithProviderClusters) consistently acrossFor,Owns, andWatches, - automatically wraps your Reconciler in
ClusterNotFoundWrapper(unless disabled).
From a user’s perspective, the code looks almost identical to single-cluster controller-runtime:
- you still:
- call
ControllerManagedBy(mgr), - specify
Forand optionalOwns/Watches, - and end with
Complete(...),
- call
- but the resulting controller:
- will watch the appropriate resources in all engaged clusters,
- will enqueue
mcreconcile.Requestvalues tagged withClusterName, - and will resolve the correct per-cluster caches and clients.
This is the final piece that closes the loop:
- Providers discover and engage clusters,
- Sources observe cluster-scoped events and enqueue multi-cluster requests,
- Controllers and Builders wire those Sources into a shared workqueue,
- Reconcilers handle
mcreconcile.Requestand use the Multi-Cluster Manager to act on the right clusters.
Together, these components let you preserve the simplicity of the controller-runtime Reconcile loop while scaling it out to fleets of clusters.