controller-runtime (Go package)
Package sigs.k8s.io/controller-runtime is used to build
Kubernetes controllers and operators.
Why is it used?
You could write all of that yourself with the raw Kubernetes
client-go library. controller-runtime provides boiler plate correct
code.
controller-runtime is the framework for “watch K8s objects and
react.” You only write the business logic inside
Reconcile().
| Without controller-runtime | With controller-runtime |
|---|---|
| Write your own watch loops and retries | Built-in Reconcile loop — your function runs when something changes |
| Call the API server on every read | Local cache of cluster objects |
| Two copies of the controller both “fix” the same thing | Leader election — only one instance runs the loop |
| Wire up HTTP health checks by hand | Manager starts controllers, metrics, and probes together |
Very simple example
Goal: whenever a Pod is created or changed, print its name and
make sure it has a label managed-by=demo-controller.
Step 1 — define the reconciler
package main
import (
"context"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
)
// PodReconciler watches Pods and adds a label if missing.
type PodReconciler struct {
client client.Client // reads/writes objects via the local cache
}
func (r *PodReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
// 1. READ — fetch the Pod that triggered this run
pod := &corev1.Pod{}
if err := r.client.Get(ctx, types.NamespacedName{
Name: req.Name,
Namespace: req.Namespace,
}, pod); err != nil {
return reconcile.Result{}, client.IgnoreNotFound(err) // Pod was deleted — ignore
}
// 2. COMPARE — do we already have the label?
if pod.Labels["managed-by"] == "demo-controller" {
return reconcile.Result{}, nil // nothing to do
}
// 3. ACT — add the label and write back to the cluster
if pod.Labels == nil {
pod.Labels = map[string]string{}
}
pod.Labels["managed-by"] = "demo-controller"
return reconcile.Result{}, r.client.Update(ctx, pod)
}
Step 2 — start the manager
import (
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
)
func main() {
ctrl.SetLogger(zap.New()) // optional: structured logs
// Manager = connection to cluster + cache + leader election
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{})
if err != nil {
panic(err)
}
// Register our PodReconciler and tell it to watch Pod objects
if err := ctrl.NewControllerManagedBy(mgr).
For(&corev1.Pod{}). // watch Pods
Complete(&PodReconciler{client: mgr.GetClient()}); err != nil {
panic(err)
}
// Blocks forever — runs Reconcile whenever a Pod changes
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
panic(err)
}
}
What happens at runtime
User runs: kubectl run nginx --image=nginx
↓
API server creates Pod "nginx"
↓
controller-runtime cache sees the new Pod
↓
PodReconciler.Reconcile() runs for Pod nginx
↓
Reconciler adds label managed-by=demo-controller
↓
kubectl get pod nginx --show-labels
NAME ... LABELS
nginx ... managed-by=demo-controller
That is the whole pattern used by real operators — just with your own
custom resource (CRD) instead of a Pod, and more logic inside
Reconcile (create Deployments, Services, Secrets, etc.).
Install in your Go module
go mod init my-operator
go get sigs.k8s.io/controller-runtime@latest
go get k8s.io/api@latest
go get k8s.io/apimachinery@latest