skip to content

A Kubernetes GameSession custom resource names a shared ConfigMap in spec.profileRef; with controller-runtime, how do you reconcile every referencing GameSession when that ConfigMap changes?

level: seniorimportance: should knowfreq 41%

answer

  1. referenced, not owned
  2. Watches plus a mapping handler
  3. field index before manager start
  4. MapFunc has no error return
  5. scope cache, predicates, RBAC

basics

~20 s

Owns cannot help because the shared ConfigMap has no single controlling owner. Add Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(fn)), where fn uses a field index on spec.profileRef to list referencing GameSessions and return their requests. Scope the ConfigMap cache with a selector.

solid answer

~40 s

`For` enqueues the primary object and `Owns` enqueues the controlling owner of a child, but a ConfigMap shared by many `GameSession`s is referenced, not owned. The fix is `Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(r.sessionsForProfile))`. The map function receives the changed ConfigMap and returns a `[]reconcile.Request`. To make it cheap, register a field index before the manager starts: `mgr.GetFieldIndexer().IndexField(ctx, &GameSession{}, ".spec.profileRef", ...)`. Then the map function runs `List` with `client.InNamespace` and `client.MatchingFields` against the cache instead of scanning every session. The queue deduplicates keys, so a burst of changes collapses into one reconcile per session. On a 210-node multi-tenant cluster, also restrict the ConfigMap cache with `cache.Options.ByObject` and a label selector, add predicates, and grant `list` and `watch` on ConfigMaps. Otherwise the operator caches and processes every ConfigMap in the cluster.

go deeper

for a junior

Remember that For watches the primary object, Owns watches children the primary controls, and Watches with a handler covers everything else.

for a middle

Explain EnqueueRequestsFromMapFunc, its signature, and why a field index turns the lookup into a cheap cache query rather than a full scan.

for a senior

Show that you control the blast radius on a shared cluster: scope the cache with a label selector, add predicates, set concurrency, grant list and watch, and handle the fact that the map function cannot retry.

for a principal

Judge whether shared referenced configuration should be a ConfigMap at all or its own custom resource with explicit status, and set conventions for reverse lookups across the platform's operators.

## The three builder verbs controller-runtime's builder (`ctrl.NewControllerManagedBy(mgr)`) connects event sources to one reconciler. Each verb creates a watch plus an **event handler** that turns an event into one or more `reconcile.Request` keys: | Verb | Handler it installs | Which key is enqueued | |---|---|---| | `For(&GameSession{})` | enqueue the object itself | the GameSession that changed | | `Owns(&appsv1.Deployment{})` | `handler.EnqueueRequestForOwner` with `OnlyControllerOwner()` | the object named by the child's controller ownerReference | | `Watches(obj, handler)` | whatever handler you pass | whatever your handler returns | `Owns` works only when the child carries a **controller** ownerReference pointing at the primary kind. `builder.MatchEveryOwner` widens that to any owner reference of that kind. How ownership and garbage collection work in general belongs to the ownership topic. Here the point is simply that a ConfigMap shared by 7,340 game sessions cannot name all of them as its controller. ## The mapping function `handler.EnqueueRequestsFromMapFunc` takes a `MapFunc` with the signature `func(ctx context.Context, obj client.Object) []reconcile.Request`. It is called for every create, update and delete event on the watched kind. A naive version lists every `GameSession` and filters in Go. That works in a demo, but on a busy cluster it runs for every ConfigMap event in every namespace. The operator's CPU climbs, and on nodes already at **91% CPU allocation** the manager pod gets throttled while its queue backs up. The better version uses a **field index**, a secondary index kept inside the informer cache: 1. **Register the index** before `mgr.Start` by calling `mgr.GetFieldIndexer().IndexField(ctx, &gamesv1.GameSession{}, ".spec.profileRef", extractFn)`. `extractFn` returns the indexed values for one object, here `[]string{gs.Spec.ProfileRef}`. 2. **Query it** in the map function with `r.List(ctx, &list, client.InNamespace(cm.Namespace), client.MatchingFields{".spec.profileRef": cm.Name})`. This is a cache lookup, not an API call. 3. **Return keys** built from each item's namespace and name. ```go const profileRefIndex = ".spec.profileRef" func (r *GameSessionReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { if err := mgr.GetFieldIndexer().IndexField(ctx, &gamesv1.GameSession{}, profileRefIndex, func(o client.Object) []string { ref := o.(*gamesv1.GameSession).Spec.ProfileRef if ref == "" { return nil } return []string{ref} }); err != nil { return err } return ctrl.NewControllerManagedBy(mgr). For(&gamesv1.GameSession{}). Owns(&appsv1.Deployment{}). Watches(&corev1.ConfigMap{}, handler.EnqueueRequestsFromMapFunc(r.sessionsForProfile), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{})). Complete(r) } func (r *GameSessionReconciler) sessionsForProfile(ctx context.Context, obj client.Object) []reconcile.Request { var list gamesv1.GameSessionList if err := r.List(ctx, &list, client.InNamespace(obj.GetNamespace()), client.MatchingFields{profileRefIndex: obj.GetName()}); err != nil { log.FromContext(ctx).Error(err, "listing sessions for profile", "configmap", obj.GetName()) return nil } reqs := make([]reconcile.Request, 0, len(list.Items)) for _, gs := range list.Items { reqs = append(reqs, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&gs)}) } return reqs } ``` A `MapFunc` **cannot return an error**. If the list fails, the handler logs and returns nothing, so that event is lost. The session is picked up at the next event or the next cache resync (`SyncPeriod` defaults to 10 hours). Keep the map function to a cache read so it essentially cannot fail. ## Controlling the blast radius On a 210-node multi-tenant platform cluster, `Watches(&corev1.ConfigMap{})` by default caches and delivers **every ConfigMap in every tenant namespace**. Narrow it in layers: - **Cache scope**: `cache.Options.ByObject` for `corev1.ConfigMap` with a `Label` selector such as `games.example.com/profile=true`. Unlabelled ConfigMaps are never listed or held in memory. - **Predicates**: `builder.WithPredicates(...)` drops events before the map function runs, for example a label predicate or `ResourceVersionChangedPredicate` to skip resync updates where nothing changed. - **Metadata only**: if the reconciler needs only the ConfigMap's name, `builder.OnlyMetadata` caches just the metadata. - **Concurrency**: `WithOptions(controller.Options{MaxConcurrentReconciles: 4})` lets a fan-out of several thousand keys drain in parallel. The work queue already deduplicates repeated keys. - **RBAC**: add `// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch` and rerun `make manifests`. Without `list` and `watch`, the ConfigMap cache never syncs, and the controller fails to start once its cache-sync timeout (2 minutes by default) expires. ## Review checklist 1. Is the reference stored in a field or label that can be indexed? 2. Is the index registered during setup, before the manager starts? 3. Does the map function read only from the cache and return quickly? 4. Is the watched kind's cache restricted by label, namespace or metadata-only? 5. Do the RBAC markers include `list` and `watch` for the watched kind, and were manifests regenerated? 6. Is there a test (envtest works well here) that changes the ConfigMap and asserts that every referencing GameSession was reconciled? ## Other shapes of the same problem - **Cross-namespace references**: the index key must include the namespace, and the map function lists across namespaces. - **Reverse lookups without a field**: store the reference in a **label** on the GameSession and list with `client.MatchingLabels`. This works even without a field index. - **Changes that should restart pods**: the reconciler can hash the ConfigMap data into a pod-template annotation, so the Deployment rolls out when the profile changes.

  • Why register the .spec.profileRef field index in SetupWithManager rather than lazily, the first time the map function needs it?
    The map function can run as soon as the first ConfigMap event arrives. A `List` with `MatchingFields` on an index that does not exist yet fails with an `index with name ... does not exist` error, and because a `MapFunc` cannot retry, that event is lost. Registering the index through `mgr.GetFieldIndexer()` during setup, before `mgr.Start`, guarantees it exists before any event is handled.
  • The team suggests adding the GameSession as an ownerReference on the shared ConfigMap so that Owns works. What is wrong with that?
    Only one ownerReference can be the controller, so `Owns` with its default `OnlyControllerOwner` would route events to a single GameSession. Adding thousands of non-controller owners bloats the ConfigMap, invites write conflicts, and changes its garbage-collection behaviour: the ConfigMap would be deleted once every listed owner is gone. A referenced object should be watched through a mapping function.
  • How do you verify that the mapping function does not overload the operator?
    Watch the controller's work-queue depth and add rate, plus reconcile duration and totals, which controller-runtime exposes on its metrics endpoint. Also watch the manager pod's CPU throttling and memory. A healthy setup shows short bursts that drain quickly. A flat, high queue depth or constant throttling means the watch is too broad or the map function is scanning rather than indexing.

Owns is a parent getting a call about their own child. A mapping function is a school announcement: a change to the shared timetable goes out to every family whose child is enrolled in that class.

saying these in an interview costs you the question

  • Owns(&corev1.ConfigMap{}) will reconcile every GameSession that references the ConfigMap
  • The map function can return an error and controller-runtime will retry the event
  • Listing all GameSessions in the map function is fine because it reads from the cache
  • Watches on a kind needs only get permission on that resource
  • Each ConfigMap event runs Reconcile once per event, even for duplicate keys