Building Custom Kubernetes Controllers in Go: From CRD to Controller
In this tutorial, you'll learn about Building Custom Kubernetes Controllers in Go: From CRD to Controller. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Custom Kubernetes controllers extend the Kubernetes API by watching Custom Resource Definitions and reconciling the desired state, enabling automation for domain-specific resources that the built-in controllers do not handle.
What You'll Learn
This tutorial covers designing Custom Resource Definitions, scaffolding a controller project with kubebuilder, implementing the reconcile loop, handling create-update-delete events, testing with envtest, and deploying the controller on a cluster.
Why It Matters
Built-in Kubernetes resources cover generic workloads but cannot model domain-specific concepts like database instances, certificate requests, or application releases. Custom controllers encapsulate operational knowledge into code that runs continuously, reducing manual intervention and human error.
Real-World Use
CoreOS built the etcd-operator using custom controllers to automate etcd cluster management -- backup, scaling, and failover. Kubernetes projects like cert-manager, Crossplane, and Knative are all custom controllers.
graph TD
A[CRD: Database] --> B[Custom Controller]
B --> C{Reconcile Loop}
C --> D[Create Deployment]
C --> E[Create Service]
C --> F[Create PVC]
D --> G[Database Pod Running]
E --> H[Network Endpoint]
F --> I[Persistent Storage]
G --> J[Status Updated on CR]
Expected output: diagram showing how a custom controller watches a Database CRD and reconciles by creating Deployment, Service, and PVC resources.
Custom Resource Definition
Define a custom resource type with validation.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databases.example.com
spec:
group: example.com
names:
kind: Database
plural: databases
singular: database
shortNames:
- db
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: ["engine", "version", "storage"]
properties:
engine:
type: string
enum: ["postgres", "mysql", "redis"]
version:
type: string
storage:
type: integer
minimum: 1
replicas:
type: integer
minimum: 1
default: 3
backup:
type: object
properties:
enabled:
type: boolean
schedule:
type: string
# Apply the CRD to the cluster
kubectl apply -f database-crd.yaml
# Verify the CRD is registered
kubectl get crd databases.example.com
# Create a custom resource instance
kubectl apply -f example-database.yaml
Expected output: the CRD shows ESTABLISHED status. Custom resources can be managed with kubectl get databases just like built-in resources.
Scaffolding a Controller with Kubebuilder
Kubebuilder generates the project structure for a controller.
# Install kubebuilder
curl -L -o kubebuilder "https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)"
chmod +x kubebuilder && sudo mv kubebuilder /usr/local/bin/
# Scaffold the project
kubebuilder init --domain example.com --repo github.com/myorg/database-operator
kubebuilder create api --group example --version v1 --kind Database --resource --controller
Expected output: the project scaffold includes a main.go, controllers/database_controller.go, and api/v1/database_types.go ready for implementation.
The Reconcile Loop
The reconcile function runs whenever a watched resource changes.
package controllers
import (
"context"
"fmt"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
databasev1 "github.com/myorg/database-operator/api/v1"
)
type DatabaseReconciler struct {
client.Client
}
func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
var db databasev1.Database
if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
if errors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
logger.Info("Reconciling Database", "engine", db.Spec.Engine, "version", db.Spec.Version)
deploymentName := fmt.Sprintf("%s-%s", db.Name, db.Spec.Engine)
var existingDeployment appsv1.Deployment
err := r.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: db.Namespace}, &existingDeployment)
if err != nil && errors.IsNotFound(err) {
deployment := r.buildDeployment(&db, deploymentName)
if err := r.Create(ctx, deployment); err != nil {
return ctrl.Result{}, err
}
logger.Info("Created deployment", "name", deploymentName)
return ctrl.Result{}, nil
}
if err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
func (r *DatabaseReconciler) buildDeployment(db *databasev1.Database, name string) *appsv1.Deployment {
replicas := db.Spec.Replicas
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: db.Namespace,
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": name},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: db.Spec.Engine,
Image: fmt.Sprintf("%s:%s", db.Spec.Engine, db.Spec.Version),
},
},
},
},
},
}
}
// SetupWithManager registers the controller with the manager
func (r *DatabaseReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&databasev1.Database{}).
Owns(&appsv1.Deployment{}).
Complete(r)
}
Testing with Envtest
Run controller tests locally without a real cluster.
func TestDatabaseReconciler(t *testing.T) {
env := &envtest.Environment{
CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")},
}
cfg, err := env.Start()
if err != nil {
t.Fatal(err)
}
defer env.Stop()
mgr, err := manager.New(cfg, manager.Options{})
if err != nil {
t.Fatal(err)
}
db := &databasev1.Database{
ObjectMeta: metav1.ObjectMeta{
Name: "test-db",
Namespace: "default",
},
Spec: databasev1.DatabaseSpec{
Engine: "postgres",
Version: "16",
Storage: 10,
Replicas: 3,
},
}
c := mgr.GetClient()
err = c.Create(context.Background(), db)
if err != nil {
t.Fatal(err)
}
var deployment appsv1.Deployment
Eventually(func() error {
return c.Get(context.Background(),
types.NamespacedName{Name: "test-db-postgres", Namespace: "default"},
&deployment)
}, time.Second*10).Should(Succeed())
}
# Run controller tests
cd database-operator
go test ./controllers/ -v
# Expected output
# --- PASS: TestDatabaseReconciler (3.45s)
# PASS
# ok github.com/myorg/database-operator/controllers 3.456s
Expected output: the test passes after the controller creates the expected Deployment within 10 seconds.
Deploying the Controller
Build and deploy the controller to the cluster.
# Build the controller image
make docker-build IMG=myregistry/database-operator:v1.0.0
# Push to registry
make docker-push IMG=myregistry/database-operator:v1.0.0
# Deploy to cluster
make deploy IMG=myregistry/database-operator:v1.0.0
# Check controller logs
kubectl -n database-operator-system logs deployment/database-operator-controller-manager
Expected output: the controller manager pod starts and begins watching for Database custom resources. Logs show the controller starting and registering watches.
Practice Questions
What triggers the reconcile function in a controller? Any create, update, or delete event on the watched custom resource or on owned resources (configured with Owns in SetupWithManager).
How does the controller handle errors during reconciliation? The reconcile function returns an error and a requeue duration. The controller retries with exponential backoff. Permanent errors should be logged and not returned to avoid infinite retry loops.
What is the purpose of the Finalizer in a custom controller? Finalizers prevent deletion of custom resources until the controller has cleaned up associated external resources (like cloud storage or DNS records). Without finalizers, the CR is deleted immediately, leaving orphaned resources.
Frequently Asked Questions
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro