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.
Search across all documentation pages
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.
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.
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:
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 Schemeclient-go clientsmgr.Start blocks until SIGINT/SIGTERM via SetupSignalHandlerAddToScheme links each Go type to its GroupVersionKind for decoding watches and lists.SetupWithManager on a reconciler registers For(), Owns(), and Watches() with the Manager's controller builder.Guestbook to webapp.example.com/v1, Kind=Guestbook for generic API calls.| 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 |
| 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 |
// 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": {},
},
},
}AddToScheme for your CRD type - reconcile Get fails with "no kind is registered". Fix: call webappv1.AddToScheme(scheme) in init().mgr.GetCache().WaitForCacheSync(ctx).LeaderElection in production Deployments.:8080 without updating Service monitors - Prometheus misses scrapes. Fix: align BindAddress with chart Service ports and NetworkPolicy.client.Client into reconcilers - bypasses shared cache and duplicates informers. Fix: always use mgr.GetClient().| 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 |
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.
Tests can substitute a fake client.
Production uses the Manager's cache-backed client without each controller opening its own informers.
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.
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.
Set Cache.DefaultNamespaces in Manager options and scope RBAC RoleBindings to that namespace.
You must call clientgoscheme.AddToScheme and add any API groups you reference (apps, core, networking, your CRDs).
Yes.
That is the default kubebuilder layout: one main.go, many SetupWithManager calls.
SetupSignalHandler cancels the Manager context, drains workers, and releases leader election leases.
metricsserver.Options binds Prometheus handlers on the Manager HTTP server alongside health probes.
The Manager builds it from discovery and your Scheme.
You rarely touch it unless writing generic controllers over arbitrary GVKs.
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).
Reviewed by Chris St. John·Last updated Jul 16, 2026