The most surprising thing about Kubernetes controllers is that they don’t "control" anything directly; they just observe the desired state and reconcile the actual state to match.
Let’s see this in action. Imagine we want to create a custom resource called MyApp that defines a deployment and a service.
# myapp.yaml
apiVersion: myapp.example.com/v1alpha1
kind: MyApp
metadata:
name: my-awesome-app
spec:
image: nginx:latest
replicas: 3
port: 80
Our controller will watch for MyApp resources. When one is created or updated, it will create or update a Kubernetes Deployment and Service to match the spec of our MyApp resource.
Here’s a simplified Go controller using controller-runtime:
package main
import (
"context"
"fmt"
"log"
"os"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/manager"
"sigs.k8s.io/controller-runtime/pkg/predicate"
"sigs.k8s.io/controller-runtime/pkg/source"
)
// Define our custom resource
type MyApp struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec MyAppSpec `json:"spec,omitempty"`
}
type MyAppSpec struct {
Image string `json:"image"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
}
// Define the list type for our custom resource
type MyAppList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []MyApp `json:"items"`
}
// Reconcile reconciles a MyApp object
func (r *MyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log.Printf("Reconciling MyApp: %s/%s", req.Namespace, req.Name)
// Fetch the MyApp instance
myApp := &MyApp{}
err := r.Get(ctx, req.NamespacedName, myApp)
if err != nil {
if errors.IsNotFound(err) {
// MyApp resource not found.
// This means it was deleted. We don't need to do anything
// as the dependent resources (Deployment, Service) will be garbage collected.
log.Printf("MyApp resource %s/%s not found, assuming deleted.", req.Namespace, req.Name)
return ctrl.Result{}, nil
}
// Error reading the object - requeue the request.
log.Printf("Error fetching MyApp %s/%s: %v", req.Namespace, req.Name, err)
return ctrl.Result{RequeueAfter: time.Second * 5}, err
}
// Reconcile the Deployment
deploymentName := fmt.Sprintf("%s-deployment", myApp.Name)
deployment := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: deploymentName,
Namespace: myApp.Namespace,
OwnerReferences: []metav1.OwnerReference{
*metav1.NewControllerRef(myApp, MyAppGroupVersionKind), // Link to MyApp
},
},
}
// Try to get the existing deployment
err = r.Get(ctx, client.ObjectKeyFromObject(deployment), deployment)
if err != nil && errors.IsNotFound(err) {
// Deployment doesn't exist, create it
log.Printf("Creating Deployment %s/%s", deployment.Namespace, deployment.Name)
// Set Deployment spec based on MyApp spec
deployment.Spec = appsv1.DeploymentSpec{
Replicas: &myApp.Spec.Replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"myapp": myApp.Name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"myapp": myApp.Name},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "app-container",
Image: myApp.Spec.Image,
Ports: []corev1.ContainerPort{{ContainerPort: myApp.Spec.Port}},
}},
},
},
}
err = r.Create(ctx, deployment)
if err != nil {
log.Printf("Error creating Deployment %s/%s: %v", deployment.Namespace, deployment.Name, err)
return ctrl.Result{}, err
}
return ctrl.Result{Requeue: true}, nil // Requeue to sync status or other dependent resources
} else if err != nil {
// Some other error occurred
log.Printf("Error getting Deployment %s/%s: %v", deployment.Namespace, deployment.Name, err)
return ctrl.Result{}, err
}
// Deployment exists, update it if necessary
log.Printf("Deployment %s/%s exists, checking for updates", deployment.Namespace, deployment.Name)
needsUpdate := false
if *deployment.Spec.Replicas != myApp.Spec.Replicas {
deployment.Spec.Replicas = &myApp.Spec.Replicas
needsUpdate = true
}
if deployment.Spec.Template.Spec.Containers[0].Image != myApp.Spec.Image {
deployment.Spec.Template.Spec.Containers[0].Image = myApp.Spec.Image
needsUpdate = true
}
if needsUpdate {
log.Printf("Updating Deployment %s/%s", deployment.Namespace, deployment.Name)
err = r.Update(ctx, deployment)
if err != nil {
log.Printf("Error updating Deployment %s/%s: %v", deployment.Namespace, deployment.Name, err)
return ctrl.Result{}, err
}
}
// Reconcile the Service
serviceName := fmt.Sprintf("%s-service", myApp.Name)
service := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: serviceName,
Namespace: myApp.Namespace,
OwnerReferences: []metav1.OwnerReference{
*metav1.NewControllerRef(myApp, MyAppGroupVersionKind), // Link to MyApp
},
},
}
err = r.Get(ctx, client.ObjectKeyFromObject(service), service)
if err != nil && errors.IsNotFound(err) {
// Service doesn't exist, create it
log.Printf("Creating Service %s/%s", service.Namespace, service.Name)
service.Spec = corev1.ServiceSpec{
Selector: map[string]string{"myapp": myApp.Name},
Ports: []corev1.ServicePort{{
Port: myApp.Spec.Port,
Name: "http",
}},
Type: corev1.ServiceTypeClusterIP,
}
err = r.Create(ctx, service)
if err != nil {
log.Printf("Error creating Service %s/%s: %v", service.Namespace, service.Name, err)
return ctrl.Result{}, err
}
return ctrl.Result{Requeue: true}, nil
} else if err != nil {
// Some other error occurred
log.Printf("Error getting Service %s/%s: %v", service.Namespace, service.Name, err)
return ctrl.Result{}, err
}
// Service exists, update it if necessary
log.Printf("Service %s/%s exists, checking for updates", service.Namespace, service.Name)
needsUpdate = false
if service.Spec.Ports[0].Port != myApp.Spec.Port {
service.Spec.Ports[0].Port = myApp.Spec.Port
needsUpdate = true
}
if needsUpdate {
log.Printf("Updating Service %s/%s", service.Namespace, service.Name)
err = r.Update(ctx, service)
if err != nil {
log.Printf("Error updating Service %s/%s: %v", service.Namespace, service.Name, err)
return ctrl.Result{}, err
}
}
// If we reach here, reconciliation is successful for now.
// We don't need to requeue unless something changes.
return ctrl.Result{}, nil
}
// Define the GroupVersionKind for MyApp
var MyAppGroupVersionKind = metav1.GroupVersionKind{Group: "myapp.example.com", Version: "v1alpha1", Kind: "MyApp"}
// MyAppReconciler reconciles a MyApp object
type MyAppReconciler struct {
client.Client
Scheme *runtime.Scheme
}
func main() {
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: runtime.NewScheme(),
})
if err != nil {
log.Fatalf("unable to start manager: %v", err)
}
// Add our custom resource to the scheme
_ = mgr.GetScheme().AddKnownTypes(MyAppGroupVersionKind.GroupVersion(), &MyApp{}, &MyAppList{})
// Create a new controller-runtime client
c, err := client.New(mgr.GetConfig(), client.Options{Scheme: mgr.GetScheme()})
if err != nil {
log.Fatalf("unable to create client: %v", err)
}
// Create a new reconciler
reconciler := &MyAppReconciler{
Client: c,
Scheme: mgr.GetScheme(),
}
// Set up the controller
c, err := controller.New("myapp-controller", mgr, controller.Options{Reconciler: reconciler})
if err != nil {
log.Fatalf("unable to create controller: %v", err)
}
// Watch for MyApp resources and enqueue them for reconciliation
if err := c.Watch(&source.Kind{Type: &MyApp{}}, &handler.EnqueueRequestForObject{}, predicate.GenerationChangedPredicate{}); err != nil {
log.Fatalf("unable to watch MyApp resources: %v", err)
}
log.Println("starting manager")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
log.Fatalf("problem running manager: %v", err)
}
}
The core of this is the Reconcile function. It’s called by controller-runtime whenever a MyApp resource changes. Inside Reconcile, we fetch the MyApp object, then we check if the corresponding Deployment and Service exist. If they don’t, we create them. If they do exist, we compare their current state to the desired state defined in MyApp.Spec and update them if they drift. The OwnerReferences field is crucial; it tells Kubernetes that the Deployment and Service are owned by the MyApp resource, ensuring they are garbage-collected when MyApp is deleted.
The controller-runtime library handles the boilerplate: setting up the Kubernetes API client, watching for resource changes, and dispatching reconciliation requests. You just define what state you want and how to get there.
The Predicate.GenerationChangedPredicate{} is a common optimization. It ensures that the Reconcile function is only called when the .metadata.generation field of the MyApp resource changes. This field increments every time the object is modified, preventing unnecessary reconciliations on simple reads or status updates.
The OwnerReferences field is where the magic of declarative management truly shines. By setting an OwnerReference, you establish a parent-child relationship. When the owner (MyApp in this case) is deleted, Kubernetes’ garbage collector automatically deletes the dependents (Deployment and Service). This is fundamental to Kubernetes’ self-healing and lifecycle management.
The next concept you’ll likely encounter is handling finalizers, which allow you to perform cleanup actions before a custom resource is actually deleted.