Skip to content

Commit e4cac69

Browse files
authored
Merge pull request #3 from embik/embik-sttts-cluster-support-enhancement
📖 Update multi-cluster proposal with new implementation details
2 parents eb207cb + 3f2fa4f commit e4cac69

File tree

1 file changed

+164
-83
lines changed

1 file changed

+164
-83
lines changed

designs/multi-cluster.md

Lines changed: 164 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# Multi-Cluster Support
2-
Author: @sttts
2+
3+
Author: @sttts @embik
4+
35
Initial implementation: @vincepri
46

5-
Last Updated on: 03/26/2024
7+
Last Updated on: 2025-01-07
68

79
## Table of Contents
810

@@ -35,6 +37,10 @@ Multi-cluster use-cases require the creation of multiple managers and/or cluster
3537
objects. This proposal is about adding native support for multi-cluster use-cases
3638
to controller-runtime.
3739

40+
With this change, it will be possible to implement pluggable cluster providers
41+
that automatically start and stop sources (and thus, cluster-aware reconcilers) when
42+
the cluster provider adds ("engages") or removes ("disengages") a cluster.
43+
3844
## Motivation
3945

4046
This change is important because:
@@ -50,27 +56,27 @@ This change is important because:
5056

5157
### Goals
5258

53-
- Provide a way to natively write controllers that
54-
1. (UNIFORM MULTI-CLUSTER CONTROLLER) operate on multiple clusters in a uniform way,
59+
- Allow 3rd-parties to implement an (optional) multi-cluster provider Go interface that controller-runtime will use (if configured on the manager) to dynamically attach and detach registered controllers to clusters that come and go.
60+
- With that, provide a way to natively write controllers for these patterns:
61+
1. (UNIFORM MULTI-CLUSTER CONTROLLERS) operate on multiple clusters in a uniform way,
5562
i.e. reconciling the same resources on multiple clusters, **optionally**
5663
- sourcing information from one central hub cluster
5764
- sourcing information cross-cluster.
5865

59-
Example: distributed `ReplicaSet` controller, reconciling `ReplicaSets` on multiple clusters.
60-
2. (AGGREGATING MULTI-CLUSTER CONTROLLER) operate on one central hub cluster aggregating information from multiple clusters.
66+
Example: distributed `ReplicaSet` controller, reconciling `ReplicaSets` on multiple clusters.
67+
2. (AGGREGATING MULTI-CLUSTER CONTROLLERS) operate on one central hub cluster aggregating information from multiple clusters.
6168

62-
Example: distributed `Deployment` controller, aggregating `ReplicaSets` back into the `Deployment` object.
63-
- Allow clusters to dynamically join and leave the set of clusters a controller operates on.
64-
- Allow event sources to be cross-cluster:
65-
1. Multi-cluster events that trigger reconciliation in the one central hub cluster.
66-
2. Central hub cluster events to trigger reconciliation on multiple clusters.
67-
- Allow (informer) indexes that span multiple clusters.
68-
- Allow logical clusters where a set of clusters is actually backed by one physical informer store.
69-
- Allow 3rd-parties to plug in their multi-cluster adapter (in source code) into
70-
an existing multi-cluster-compatible code-base.
69+
Example: distributed `Deployment` controller, aggregating `ReplicaSets` across multiple clusters back into a central `Deployment` object.
70+
71+
#### Low-Level Requirements
72+
73+
- Allow event sources to be cross-cluster such that:
74+
1. Multi-cluster events can trigger reconciliation in the one central hub cluster.
75+
2. Central hub cluster events can trigger reconciliation on multiple clusters.
76+
- Allow reconcilers to look up objects through (informer) indexes from specific other clusters.
7177
- Minimize the amount of changes to make a controller-runtime controller
7278
multi-cluster-compatible, in a way that 3rd-party projects have no reason to
73-
object these kind of changes.
79+
object to these kind of changes.
7480

7581
Here we call a controller to be multi-cluster-compatible if the reconcilers get
7682
reconcile requests in cluster `X` and do all reconciliation in cluster `X`. This
@@ -80,7 +86,7 @@ logic.
8086
### Examples
8187

8288
- Run a controller-runtime controller against a kubeconfig with arbitrary many contexts, all being reconciled.
83-
- Run a controller-runtime controller against cluster-managers like kind, Cluster-API, Open-Cluster-Manager or Hypershift.
89+
- Run a controller-runtime controller against cluster managers like kind, Cluster API, Open-Cluster-Manager or Hypershift.
8490
- Run a controller-runtime controller against a kcp shard with a wildcard watch.
8591

8692
### Non-Goals/Future Work
@@ -94,17 +100,31 @@ logic.
94100
## Proposal
95101

96102
The `ctrl.Manager` _SHOULD_ be extended to get an optional `cluster.Provider` via
97-
`ctrl.Options` implementing
103+
`ctrl.Options`, implementing:
98104

99105
```golang
100106
// pkg/cluster
107+
108+
// Provider defines methods to retrieve clusters by name. The provider is
109+
// responsible for discovering and managing the lifecycle of each cluster.
110+
//
111+
// Example: A Cluster API provider would be responsible for discovering and
112+
// managing clusters that are backed by Cluster API resources, which can live
113+
// in multiple namespaces in a single management cluster.
101114
type Provider interface {
102-
Get(ctx context.Context, clusterName string, opts ...Option) (Cluster, error)
103-
List(ctx context.Context) ([]string, error)
104-
Watch(ctx context.Context) (Watcher, error)
115+
// Get returns a cluster for the given identifying cluster name. Get
116+
// returns an existing cluster if it has been created before.
117+
Get(ctx context.Context, clusterName string) (Cluster, error)
105118
}
106119
```
120+
121+
A cluster provider is responsible for constructing `cluster.Cluster` instances and returning
122+
upon calls to `Get(ctx, clusterName)`. Providers should keep track of created clusters and
123+
return them again if the same name is requested. Since providers are responsible for constructing
124+
the `cluster.Cluster` instance, they can make decisions about e.g. reusing existing informers.
125+
107126
The `cluster.Cluster` _SHOULD_ be extended with a unique name identifier:
127+
108128
```golang
109129
// pkg/cluster:
110130
type Cluster interface {
@@ -113,59 +133,106 @@ type Cluster interface {
113133
}
114134
```
115135

116-
The `ctrl.Manager` will use the provider to watch clusters coming and going, and
117-
will inform runnables implementing the `cluster.AwareRunnable` interface:
136+
A new interface for cluster-aware runnables will be provided:
118137

119138
```golang
120139
// pkg/cluster
121-
type AwareRunnable interface {
140+
type Aware interface {
141+
// Engage gets called when the component should start operations for the given Cluster.
142+
// The given context is tied to the Cluster's lifecycle and will be cancelled when the
143+
// Cluster is removed or an error occurs.
144+
//
145+
// Implementers should return an error if they cannot start operations for the given Cluster,
146+
// and should ensure this operation is re-entrant and non-blocking.
147+
//
148+
// \_________________|)____.---'--`---.____
149+
// || \----.________.----/
150+
// || / / `--'
151+
// __||____/ /_
152+
// |___ \
153+
// `--------'
122154
Engage(context.Context, Cluster) error
155+
156+
// Disengage gets called when the component should stop operations for the given Cluster.
123157
Disengage(context.Context, Cluster) error
124158
}
125159
```
126-
In particular, controllers implement the `AwareRunnable` interface. They react
127-
to engaged clusters by duplicating and starting their registered `source.Source`s
128-
and `handler.EventHandler`s for each cluster through implementation of
129-
```golang
130-
// pkg/source
131-
type DeepCopyableSyncingSource interface {
132-
SyncingSource
133-
DeepCopyFor(cluster cluster.Cluster) DeepCopyableSyncingSource
134-
}
135160

136-
// pkg/handler
137-
type DeepCopyableEventHandler interface {
138-
EventHandler
139-
DeepCopyFor(c cluster.Cluster) DeepCopyableEventHandler
161+
`ctrl.Manager` will implement `cluster.Aware`. As specified in the `Provider` interface,
162+
it is the cluster provider's responsibility to call `Engage` and `Disengage` on a `ctrl.Manager`
163+
instance when clusters join or leave the set of target clusters that should be reconciled.
164+
165+
The internal `ctrl.Manager` implementation in turn will call `Engage` and `Disengage` on all
166+
its runnables that are cluster-aware (i.e. that implement the `cluster.Aware` interface).
167+
168+
In particular, cluster-aware controllers implement the `cluster.Aware` interface and are
169+
responsible for starting watches on clusters when they are engaged. This is expressed through
170+
the interface below:
171+
172+
```golang
173+
// pkg/controller
174+
type TypedMultiClusterController[request comparable] interface {
175+
cluster.Aware
176+
TypedController[request]
140177
}
141178
```
142-
The standard implementing types, in particular `internal.Kind` will adhere to
143-
these interfaces.
179+
180+
The multi-cluster controller implementation reacts to engaged clusters by starting
181+
a new `TypedSyncingSource` that also wraps the context passed down from the call to `Engage`,
182+
which _MUST_ be canceled by the cluster provider at the end of a cluster's lifecycle.
144183

145184
The `ctrl.Manager` _SHOULD_ be extended by a `cluster.Cluster` getter:
185+
146186
```golang
147187
// pkg/manager
148188
type Manager interface {
149189
// ...
150190
GetCluster(ctx context.Context, clusterName string) (cluster.Cluster, error)
151191
}
152192
```
193+
153194
The embedded `cluster.Cluster` corresponds to `GetCluster(ctx, "")`. We call the
154195
clusters with non-empty name "provider clusters" or "enganged clusters", while
155196
the embedded cluster of the manager is called the "default cluster" or "hub
156197
cluster".
157198

158-
The `reconcile.Request` _SHOULD_ be extended by an optional `ClusterName` field:
199+
To provide information about the source cluster of a request, a new type
200+
`reconcile.ClusterAwareRequest` _SHOULD_ be added:
201+
202+
```golang
203+
// pkg/reconcile
204+
type ClusterAwareRequest struct {
205+
Request
206+
ClusterName string
207+
}
208+
```
209+
210+
This struct embeds a `reconcile.Request` to store the "usual" information (name and namespace)
211+
about an object, plus the name of the originating cluster.
212+
213+
Given that an empty cluster name represents the "default cluster", a `reconcile.ClusterAwareRequest`
214+
can be used as `request` type even for controllers that do not have an active cluster provider.
215+
The cluster name will simply be an empty string, which is compatible with calls to `mgr.GetCluster`.
216+
217+
### BYO Request Type
218+
219+
Instead of using the new `reconcile.ClusterAwareRequest`, implementations _CAN_ also bring their
220+
own request type through the generics support in `Typed*` types (`request comparable`).
221+
222+
Optionally, a passed `TypedEventHandler` will be duplicated per engaged cluster if they
223+
fullfil the following interface:
224+
159225
```golang
160-
// pkg/reconile
161-
type Request struct {
162-
ClusterName string
163-
types.NamespacedName
226+
// pkg/handler
227+
type TypedDeepCopyableEventHandler[object any, request comparable] interface {
228+
TypedEventHandler[object, request]
229+
DeepCopyFor(c cluster.Cluster) TypedDeepCopyableEventHandler[object, request]
164230
}
165231
```
166232

167-
With these changes, the behaviour of controller-runtime without a set cluster
168-
provider will be unchanged.
233+
This might be necessary if a BYO `TypedEventHandler` needs to store information about
234+
the engaged cluster (e.g. because the events do not supply information about the cluster in
235+
object annotations) that it has been started for.
169236

170237
### Multi-Cluster-Compatible Reconcilers
171238

@@ -174,43 +241,70 @@ accessing code from directly accessing `mgr.GetClient()` and `mgr.GetCache()` to
174241
going through `mgr.GetCluster(req.ClusterName).GetClient()` and
175242
`mgr.GetCluster(req.ClusterName).GetCache()`.
176243

177-
When building a controller like
244+
A typical snippet at the beginning of a reconciler to fetch the client could look like this:
245+
178246
```golang
179-
builder.NewControllerManagedBy(mgr).
180-
For(&appsv1.ReplicaSet{}).
181-
Owns(&v1.Pod{}).
182-
Complete(reconciler)
247+
cl, err := mgr.GetCluster(ctx, req.ClusterName)
248+
if err != nil {
249+
return reconcile.Result{}, err
250+
}
251+
client := cl.GetClient()
252+
```
253+
254+
Due to `request.ClusterAwareRequest`, changes to the controller builder process are minimal:
255+
256+
```golang
257+
// previous
258+
builder.TypedControllerManagedBy[reconcile.Request](mgr).
259+
Named("single-cluster-controller").
260+
For(&corev1.Pod{}).
261+
Complete(reconciler)
262+
263+
// new
264+
builder.TypedControllerManagedBy[reconcile.ClusterAwareRequest](mgr).
265+
Named("multi-cluster-controller").
266+
For(&corev1.Pod{}).
267+
Complete(reconciler)
183268
```
184-
with the described change to use `GetCluster(ctx, req.ClusterName)` will automatically
185-
act as *uniform multi-cluster controller*. It will reconcile resources from cluster `X`
186-
in cluster `X`.
269+
270+
The builder will chose the correct `EventHandler` implementation for both `For` and `Owns`
271+
depending on the `request` type used.
272+
273+
With the described changes (use `GetCluster(ctx, req.ClusterName)`, making `reconciler`
274+
a `TypedFunc[reconcile.ClusterAwareRequest]`) an existing controller will automatically act as
275+
*uniform multi-cluster controller* if a cluster provider is configured.
276+
It will reconcile resources from cluster `X` in cluster `X`.
187277

188278
For a manager with `cluster.Provider`, the builder _SHOULD_ create a controller
189279
that sources events **ONLY** from the provider clusters that got engaged with
190280
the controller.
191281

192-
Controllers that should be triggered by events on the hub cluster will have to
193-
opt-in like in this example:
282+
Controllers that should be triggered by events on the hub cluster can continue
283+
to use `For` and `Owns` and explicitly pass the intention to engage only with the
284+
"default" cluster (this is only necessary if a cluster provider is plugged in):
194285

195286
```golang
196287
builder.NewControllerManagedBy(mgr).
197-
For(&appsv1.Deployment{}, builder.InDefaultCluster).
288+
WithOptions(controller.TypedOptions{
289+
EngageWithDefaultCluster: ptr.To(true),
290+
EngageWithProviderClusters: ptr.To(false),
291+
}).
292+
For(&appsv1.Deployment{}).
198293
Owns(&v1.ReplicaSet{}).
199294
Complete(reconciler)
200295
```
201-
A mixed set of sources is possible as shown here in the example.
202296

203297
## User Stories
204298

205299
### Controller Author with no interest in multi-cluster wanting to old behaviour.
206300

207301
- Do nothing. Controller-runtime behaviour is unchanged.
208302

209-
### Multi-Cluster Integrator wanting to support cluster managers like Cluster-API or kind
303+
### Multi-Cluster Integrator wanting to support cluster managers like Cluster API or kind
210304

211305
- Implement the `cluster.Provider` interface, either via polling of the cluster registry
212306
or by watching objects in the hub cluster.
213-
- For every new cluster create an instance of `cluster.Cluster`.
307+
- For every new cluster create an instance of `cluster.Cluster` and call `mgr.Engage`.
214308

215309
### Multi-Cluster Integrator wanting to support apiservers with logical cluster (like kcp)
216310

@@ -223,23 +317,22 @@ A mixed set of sources is possible as shown here in the example.
223317
### Controller Author without self-interest in multi-cluster, but open for adoption in multi-cluster setups
224318

225319
- Replace `mgr.GetClient()` and `mgr.GetCache` with `mgr.GetCluster(req.ClusterName).GetClient()` and `mgr.GetCluster(req.ClusterName).GetCache()`.
226-
- Make manager and controller plumbing vendor'able to allow plugging in multi-cluster provider.
320+
- Make manager and controller plumbing vendor'able to allow plugging in multi-cluster provider and BYO request type.
227321

228322
### Controller Author who wants to support certain multi-cluster setups
229323

230324
- Do the `GetCluster` plumbing as described above.
231-
- Vendor 3rd-party multi-cluster providers and wire them up in `main.go`
325+
- Vendor 3rd-party multi-cluster providers and wire them up in `main.go`.
232326

233327
## Risks and Mitigations
234328

235329
- The standard behaviour of controller-runtime is unchanged for single-cluster controllers.
236-
- The activation of the multi-cluster mode is through attaching the `cluster.Provider` to the manager.
237-
To make it clear that the semantics are experimental, we make the `Options.provider` field private
238-
and adds `Options.WithExperimentalClusterProvider` method.
330+
- The activation of the multi-cluster mode is through usage of a `request.ClusterAwareRequest` request type and
331+
attaching the `cluster.Provider` to the manager. To make it clear that the semantics are experimental, we name
332+
the `manager.Options` field `ExperimentalClusterProvider`.
239333
- We only extend these interfaces and structs:
240-
- `ctrl.Manager` with `GetCluster(ctx, clusterName string) (cluster.Cluster, error)`
241-
- `cluster.Cluster` with `Name() string`
242-
- `reconcile.Request` with `ClusterName string`
334+
- `ctrl.Manager` with `GetCluster(ctx, clusterName string) (cluster.Cluster, error)` and `cluster.Aware`.
335+
- `cluster.Cluster` with `Name() string`.
243336
We think that the behaviour of these extensions is well understood and hence low risk.
244337
Everything else behind the scenes is an implementation detail that can be changed
245338
at any time.
@@ -258,24 +351,12 @@ A mixed set of sources is possible as shown here in the example.
258351
- We could deepcopy the builder instead of the sources and handlers. This would
259352
lead to one controller and one workqueue per cluster. For the reason outlined
260353
in the previous alternative, this is not desireable.
261-
- We could skip adding `ClusterName` to `reconcile.Request` and instead pass the
262-
cluster through in the context. On the one hand, this looks attractive as it
263-
would avoid having to touch reconcilers at all to make them multi-cluster-compatible.
264-
On the other hand, with `cluster.Cluster` embedded into `manager.Manager`, not
265-
every method of `cluster.Cluster` carries a context. So virtualizing the cluster
266-
in the manager leads to contradictions in the semantics.
267-
268-
For example, it can well be that every cluster has different REST mapping because
269-
installed CRDs are different. Without a context, we cannot return the right
270-
REST mapper.
271-
272-
An alternative would be to add a context to every method of `cluster.Cluster`,
273-
which is a much bigger and uglier change than what is proposed here.
274-
275354

276355
## Implementation History
277356

278357
- [PR #2207 by @vincepri : WIP: ✨ Cluster Provider and cluster-aware controllers](https://github.com/kubernetes-sigs/controller-runtime/pull/2207) – with extensive review
279-
- [PR #2208 by @sttts replace #2207: WIP: ✨ Cluster Provider and cluster-aware controllers](https://github.com/kubernetes-sigs/controller-runtime/pull/2726)
358+
- [PR #2726 by @sttts replacing #2207: WIP: ✨ Cluster Provider and cluster-aware controllers](https://github.com/kubernetes-sigs/controller-runtime/pull/2726)
280359
picking up #2207, addressing lots of comments and extending the approach to what kcp needs, with a `fleet-namespace` example that demonstrates a similar setup as kcp with real logical clusters.
360+
- [PR #3019 by @embik, replacing #2726: ✨ WIP: Cluster provider and cluster-aware controllers](https://github.com/kubernetes-sigs/controller-runtime/pull/3019) -
361+
picking up #2726, reworking existing code to support the recent `Typed*` generic changes of the codebase.
281362
- [github.com/kcp-dev/controller-runtime](https://github.com/kcp-dev/controller-runtime) – the kcp controller-runtime fork

0 commit comments

Comments
 (0)