controller-runtime Manager, Client & Scheme
Every production Go operator centers on a controller-runtime Manager that shares caches and lifecycle hooks across reconcilers, webhooks, and health endpoints.
The typed Client and Scheme are the two objects reconcilers use on every loop.
Summary
The Manager starts informer caches, exposes GetClient() and GetScheme(), and runs until SIGTERM.
The Client performs Get/List/Create/Update/Patch/Delete against Kubernetes objects, preferring cache reads for watched types.
The Scheme registers your CRD Go types plus core API types so the REST mapper can decode unstructured objects into structs.
Bootstrapping happens once in cmd/main.go; reconcilers receive dependencies via struct fields.
Recipe
Quick-reference recipe card - copy-paste ready.
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: ":8080"},
HealthProbeBindAddress: ":8081",
LeaderElection: true,
LeaderElectionID: "guestbook.example.com",
})When to reach for this:
- Starting any kubebuilder or controller-runtime operator
- Sharing one cache across multiple reconcilers in a single process
- Enabling metrics, health probes, and leader election in one place
- Registering admission webhooks on the same HTTP server as metrics
Working Example
package main
import (
"os"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/healthz"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
webappv1 "example.com/guestbook-operator/api/v1"
"example.com/guestbook-operator/internal/controller"
)
var scheme = runtime.NewScheme()
func init() {
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
utilruntime.Must(appsv1.AddToScheme(scheme))
utilruntime.Must(corev1.AddToScheme(scheme))
utilruntime.Must(webappv1.AddToScheme(scheme))
}
func main() {
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: "0"},
HealthProbeBindAddress: "0",
LeaderElection: false, // true in production
LeaderElectionID: "guestbook.example.com",
})
if err != nil {
panic(err)
}
if err := (&controller.GuestbookReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
panic(err)
}
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
panic(err)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
panic(err)
}
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
os.Exit(1)
}
}What this demonstrates:
init()registers built-in and custom API types on a shared Scheme- Manager options wire metrics, probes, and optional leader election
- Reconcilers receive the Manager's Client and Scheme, not raw
client-goclients mgr.Startblocks until SIGINT/SIGTERM viaSetupSignalHandler
Deep Dive
How It Works
- The Manager constructs a delegating client: reads hit the shared informer cache; writes go directly to the API server.
AddToSchemelinks each Go type to its GroupVersionKind for decoding watches and lists.SetupWithManageron a reconciler registersFor(),Owns(), andWatches()with the Manager's controller builder.- RESTMapper inside the Manager resolves
Guestbooktowebapp.example.com/v1, Kind=Guestbookfor generic API calls.
Manager Options at a Glance
| Option | Purpose |
|---|---|
Scheme | Types the Manager can decode |
Metrics.BindAddress | Prometheus scrape endpoint (:8080) |
HealthProbeBindAddress | /healthz and /readyz for kube probes |
LeaderElection | Single active replica when replicas > 1 |
LeaderElectionID | Lease object name in coordination API |
Cache.DefaultNamespaces | Restrict informers to listed namespaces |
Client vs Raw client-go
| Operation | controller-runtime Client | Notes |
|---|---|---|
Get / List | Cache-backed for watched types | Fast, eventually consistent |
Create / Update / Patch / Delete | Direct API | Strong consistency |
Status().Update | Subresource write | Requires status subresource on CRD |
Go Notes
// Use APIReader when you must bypass cache (consistency-critical reads)
apiReader := mgr.GetAPIReader()
// Namespace-scoped operators: restrict cache in Manager options
ctrl.Options{
Cache: cache.Options{
DefaultNamespaces: map[string]cache.Config{
"tenant-a": {},
},
},
}Gotchas
- Forgetting
AddToSchemefor your CRD type - reconcileGetfails with "no kind is registered". Fix: callwebappv1.AddToScheme(scheme)ininit(). - Using the Client immediately before caches sync - first reconcile may see NotFound for existing objects. Fix: rely on requeue or use
mgr.GetCache().WaitForCacheSync(ctx). - Leader election off with multiple replicas - every Pod reconciles the same objects, causing duplicate work and races. Fix: enable
LeaderElectionin production Deployments. - Binding metrics to
:8080without updating Service monitors - Prometheus misses scrapes. Fix: alignBindAddresswith chart Service ports and NetworkPolicy. - Passing a fresh
client.Clientinto reconcilers - bypasses shared cache and duplicates informers. Fix: always usemgr.GetClient(). - Mixing unstructured and typed clients without Scheme - decoding errors on generic objects. Fix: register all watched GVKs on the Manager Scheme.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| controller-runtime Manager | Standard kubebuilder operators | You need only a one-shot CLI against the API |
| Raw client-go informers | Maximum control over informer wiring | You want webhooks, metrics, and leader election integrated |
| client-go dynamic client | Generic controllers without Go types | You have generated typed APIs and status subresources |
| Operator SDK (Java) | JVM-standardized teams | You want kubebuilder codegen and envtest |
FAQs
What does the Manager actually run?
It starts shared informer caches, registers controllers and webhooks, serves metrics and health checks, and blocks on Start until shutdown.
All reconcilers in the process share one cache.
Why inject Client and Scheme into reconcilers?
Tests can substitute a fake client.
Production uses the Manager's cache-backed client without each controller opening its own informers.
When should I use GetAPIReader instead of Get?
When you cannot tolerate stale cache reads, such as immediately after creating an object outside the watch path.
Most reconciles should use the caching client.
What is LeaderElectionID?
It names the Lease or ConfigMap lock object in the coordination.k8s.io API.
All replicas must use the same ID to elect one leader.
How do I limit an operator to one namespace?
Set Cache.DefaultNamespaces in Manager options and scope RBAC RoleBindings to that namespace.
Does Scheme include core types automatically?
You must call clientgoscheme.AddToScheme and add any API groups you reference (apps, core, networking, your CRDs).
Can multiple reconcilers share one Manager?
Yes.
That is the default kubebuilder layout: one main.go, many SetupWithManager calls.
What happens on SIGTERM?
SetupSignalHandler cancels the Manager context, drains workers, and releases leader election leases.
How do metrics relate to the Manager?
metricsserver.Options binds Prometheus handlers on the Manager HTTP server alongside health probes.
Is RESTMapper something I configure manually?
The Manager builds it from discovery and your Scheme.
You rarely touch it unless writing generic controllers over arbitrary GVKs.
Related
- Kubernetes Operators Basics - scaffold and first reconcile
- Writing a Reconciler: ctrl.Request & Reconcile Loop - Reconcile return values
- Leader Election, Metrics & Operator Observability - probes and Prometheus
- Operator Testing with envtest & fake client - test Scheme setup
- Why Go Dominates the Kubernetes Operator Ecosystem - ecosystem context
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).