Abstract Dive into the design and implementation of custom Kubernetes operators to manage stateful applications. Learn about operator patterns, SDKs, and testing strategies to streamline deployment lifecycles.
Introduction
Kubernetes operators extend Kubernetes’ declarative model to automate complex application management tasks. Custom operators encapsulate operational knowledge for deploying, scaling, and healing stateful applications—such as databases, message queues, and storage systems—by implementing custom controllers. Instead of relying on generic Helm charts or ad hoc scripts, operators embed application-specific logic into Kubernetes-native APIs.
In this article, we explore:
- Operator Patterns: Common design paradigms—Reconcile loops, CRDs (Custom Resource Definitions), and finalizers—to manage stateful workloads.
- Operator SDKs and Frameworks: Overview of popular SDKs (Operator SDK, Kubebuilder) and how they simplify code generation.
- Implementation Guidance: Step-by-step process to scaffold, build, and package an operator with Go and the Operator SDK.
- Testing Strategies: Unit testing, integration testing, and end-to-end (E2E) tests to ensure reliability.
- Best Practices: Tips for handling upgrades, managing CRD versions, and ensuring high availability.
By the end, you’ll understand how to build operators that manage complex stateful applications at scale, reducing manual intervention and improving reliability.
1. Operator Patterns and Architecture
1.1 Custom Resource Definitions (CRDs)
At the heart of any operator is a Custom Resource Definition (CRD). CRDs enable users to extend the Kubernetes API with their own resource types. For a MySQL operator, for example, a CRD might define a MySQLCluster
resource with fields like:
apiVersion: databases.example.com/v1alpha1
kind: MySQLCluster
metadata:
name: sample-cluster
spec:
replicas: 3
version: "8.0"
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "1"
memory: "2Gi"
Kubernetes stores the CR fields in etcd. When a MySQLCluster
object is created, updated, or deleted, the operator’s controller is notified.
1.2 Reconciliation Loop
An operator’s controller implements a Reconcile function, which is invoked whenever the desired state (CR) changes or periodically for sanity checks. The Reconcile function:
- Retrieves the custom resource instance.
- Reads the current state of application components (e.g., StatefulSets, Services).
- Compares current state vs. desired state from the CR’s
spec
. - Takes actions to converge: creating or updating Kubernetes objects, managing upgrades, and handling failures.
Pseudo-code:
func (r *MySQLClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// 1. Fetch MySQLCluster instance
var cluster databasesv1alpha1.MySQLCluster
if err := r.Get(ctx, req.NamespacedName, &cluster); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 2. Read existing StatefulSet
var sts appsv1.StatefulSet
err := r.Get(ctx, types.NamespacedName{Name: cluster.Name, Namespace: cluster.Namespace}, &sts)
// 3. Compare spec:
desiredReplicas := int32(cluster.Spec.Replicas)
if sts.Spec.Replicas == nil || *sts.Spec.Replicas != desiredReplicas {
// 4a. Update or create StatefulSet
newSts := generateStatefulSet(cluster)
if err := r.CreateOrUpdate(ctx, &newSts); err != nil {
return ctrl.Result{}, err
}
}
// 5. Update status
cluster.Status.ReadyReplicas = sts.Status.ReadyReplicas
if err := r.Status().Update(ctx, &cluster); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: time.Minute * 5}, nil
}
1.3 Finalizers and Cleanup
When a CR is deleted, its associated resources must be cleaned up gracefully (e.g., deleting PVCs or backups). Operators use finalizers—strings added to the CR’s metadata—to delay deletion until cleanup is complete.
- Add Finalizer: Upon creating a CR, operator adds
finalizers: ["mysqlcluster.finalizers.example.com"]
. - Detect Deletion: In Reconcile, if
DeletionTimestamp
is set, perform cleanup tasks (e.g.,deleteBackupResources()
). - Remove Finalizer: After cleanup, remove finalizer and update CR; Kubernetes then deletes the CR.
if !cluster.ObjectMeta.DeletionTimestamp.IsZero() {
if containsString(cluster.Finalizers, finalizerName) {
// Perform cleanup logic
if err := r.deleteBackupResources(cluster); err != nil {
return ctrl.Result{}, err
}
// Remove finalizer
cluster.Finalizers = removeString(cluster.Finalizers, finalizerName)
if err := r.Update(ctx, &cluster); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
2. Operator SDKs and Frameworks
Several frameworks simplify operator development by generating boilerplate and providing utilities.
2.1 Operator SDK (by Operator Framework)
- CLI Scaffolding:
operator-sdk init --domain example.com --repo github.com/example/mysql-operator
creates a Go module, directory structure, and boilerplate. - API and Controller:
operator-sdk create api --group databases --version v1alpha1 --kind MySQLCluster
generates CRD definitions and placeholder controller code. - Controller Utilities: SDK provides
controller-runtime
integration for leader election, rate limiting, and client caching. - Image Building: Integrates with
docker
orbuildah
for operator image building. - Scorecard Tests: Enables Conformance and OLM scorecard tests for validating operator behavior.
2.2 Kubebuilder
An alternative framework leveraging controller-runtime
:
- Initialize Project:
kubebuilder init --domain example.com --repo github.com/example/mysql-operator
. - Create API:
kubebuilder create api --group databases --version v1alpha1 --kind MySQLCluster
. - Edit CRD Schema: Modify
api/v1alpha1/mysqlcluster_types.go
to define spec fields, default values, and validation. - Generate Manifests:
make manifests
to create CRD YAML inconfig/crd/bases
. - Implement Controller: Edit
controllers/mysqlcluster_controller.go
to add Reconcile logic. - Run Locally:
make run
starts the manager with webhook and controller. - Build Docker Image:
make docker-build IMG=example/mysql-operator:v0.1.0
.
2.3 Java and Python Operator Frameworks
- Java (Java Operator SDK): Allows building operators in Java using Quarkus or Spring Boot. CRD generation from Java classes annotated with
@Group
,@Version
, and@Kind
. - Python ( Kopf):
kopf
(Kubernetes Operator Pythonic Framework) enables writing operators in Python. Example:
import kopf
import kubernetes.client as k8s_client
@kopf.on.create('databases.example.com', 'v1alpha1', 'mysqlclusters')
def create_fn(spec, name, namespace, **kwargs):
replicas = spec.get('replicas', 1)
# Create StatefulSet via k8s_client
sts = generate_statefulset_object(name, namespace, replicas)
api = k8s_client.AppsV1Api()
api.create_namespaced_stateful_set(namespace, sts)
While Go SDKs remain most common, Python and Java frameworks suit teams more comfortable in those languages.
3. Step-by-Step Operator Implementation
We outline building a simple MySQL operator using the Operator SDK (Go).
3.1 Scaffold the Project
# Initialize project
operator-sdk init --domain example.com --repo github.com/yourorg/mysql-operator
# Create API and Controller
operator-sdk create api --group databases --version v1alpha1 --kind MySQLCluster --resource --controller
# Generate CRD manifests
make manifests
The scaffold generates:
api/v1alpha1/mysqlcluster_types.go
: CRD schema definitions.controllers/mysqlcluster_controller.go
: Reconcile stub.config/crd/bases/databases.example.com_mysqlclusters.yaml
: CRD YAML.
3.2 Define the CRD Schema
Edit api/v1alpha1/mysqlcluster_types.go
:
// MySQLClusterSpec defines the desired state
type MySQLClusterSpec struct {
// Number of MySQL replicas
Replicas int32 `json:"replicas" validate:"min=1"`
// MySQL version
Version string `json:"version"`
// Resource requirements for pods
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
}
// MySQLClusterStatus defines the observed state
type MySQLClusterStatus struct {
// Number of ready replicas
ReadyReplicas int32 `json:"readyReplicas"`
// Phase: Pending, Running, Failed
Phase string `json:"phase"`
}
Generate deep copy and CRD files:
make generate
make manifests
3.3 Implement the Reconciler
In controllers/mysqlcluster_controller.go
:
func (r *MySQLClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// 1. Fetch custom resource
var cluster databasesv1alpha1.MySQLCluster
if err := r.Get(ctx, req.NamespacedName, &cluster); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 2. Ensure finalizer
finalizerName := "mysqlcluster.finalizers.example.com"
if cluster.ObjectMeta.DeletionTimestamp.IsZero() {
if !containsString(cluster.ObjectMeta.Finalizers, finalizerName) {
cluster.ObjectMeta.Finalizers = append(cluster.ObjectMeta.Finalizers, finalizerName)
if err := r.Update(ctx, &cluster); err != nil {
return ctrl.Result{}, err
}
}
}
// 3. Handle deletion
if !cluster.ObjectMeta.DeletionTimestamp.IsZero() {
if containsString(cluster.ObjectMeta.Finalizers, finalizerName) {
if err := r.cleanupResources(ctx, &cluster); err != nil {
return ctrl.Result{}, err
}
cluster.ObjectMeta.Finalizers = removeString(cluster.ObjectMeta.Finalizers, finalizerName)
if err := r.Update(ctx, &cluster); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
// 4. Reconcile StatefulSet
sts := &appsv1.StatefulSet{}
err := r.Get(ctx, types.NamespacedName{Name: cluster.Name, Namespace: cluster.Namespace}, sts)
if err != nil && apierrors.IsNotFound(err) {
// Create new StatefulSet
newSts := r.generateStatefulSet(&cluster)
if err := r.Create(ctx, newSts); err != nil {
return ctrl.Result{}, err
}
} else if err != nil {
return ctrl.Result{}, err
} else {
// Update existing if spec changed
desiredReplicas := cluster.Spec.Replicas
if sts.Spec.Replicas == nil || *sts.Spec.Replicas != desiredReplicas {
sts.Spec.Replicas = &desiredReplicas
if err := r.Update(ctx, sts); err != nil {
return ctrl.Result{}, err
}
}
}
// 5. Update status
var updatedSts appsv1.StatefulSet
if err := r.Get(ctx, types.NamespacedName{Name: cluster.Name, Namespace: cluster.Namespace}, &updatedSts); err != nil {
return ctrl.Result{}, err
}
cluster.Status.ReadyReplicas = updatedSts.Status.ReadyReplicas
cluster.Status.Phase = determinePhase(updatedSts)
if err := r.Status().Update(ctx, &cluster); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: time.Minute * 5}, nil
}
Helper functions:
func (r *MySQLClusterReconciler) generateStatefulSet(cluster *databasesv1alpha1.MySQLCluster) *appsv1.StatefulSet {
labels := map[string]string{"app": cluster.Name}
replicas := cluster.Spec.Replicas
return &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: cluster.Name,
Namespace: cluster.Namespace,
},
Spec: appsv1.StatefulSetSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{MatchLabels: labels},
ServiceName: cluster.Name,
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: labels},
Spec: corev1.PodSpec{
Containers: []corev1.Container,
Resources: cluster.Spec.Resources,
Env: []corev1.EnvVar,
}},
},
},
VolumeClaimTemplates: []corev1.PersistentVolumeClaim{...},
},
}
}
func determinePhase(sts appsv1.StatefulSet) string {
if sts.Status.ReadyReplicas == *sts.Spec.Replicas {
return "Running"
}
return "Pending"
}
func (r *MySQLClusterReconciler) cleanupResources(ctx context.Context, cluster *databasesv1alpha1.MySQLCluster) error {
// Delete backups, PVCs, etc.
return nil
}
3.4 Build and Deploy the Operator
# Build operator image
docker build -t yourorg/mysql-operator:v0.1.0 .
# Push to registry
docker push yourorg/mysql-operator:v0.1.0
# Apply CRDs and RBAC
overlay/kustomize/ or config/manager/* YAMLs
kubectl apply -f config/crd/bases
kubectl apply -f config/manager/service_account.yaml
kubectl apply -f config/manager/role.yaml
kubectl apply -f config/manager/role_binding.yaml
kubectl apply -f config/manager/manager.yaml
After deploying, the operator’s Pod watches for MySQLCluster
CRs. Create a sample CR:
apiVersion: databases.example.com/v1alpha1
kind: MySQLCluster
metadata:
name: example-cluster
spec:
replicas: 3
version: "8.0"
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "1"
memory: "2Gi"
Verify the operator creates a StatefulSet and corresponding Pods.
4. Testing Strategies
Robust testing ensures operators behave correctly under different scenarios.
4.1 Unit Testing Reconcile Logic
- Reconcile Tests: Use the
envtest
package (fromcontroller-runtime
) to bootstrap a real API server locally. Create fakeMySQLCluster
objects and simulate existing StatefulSets. Assert that Reconcile generates expected CRUD actions.
func TestReconcileCreatesStatefulSet(t *testing.T) {
scheme := runtime.NewScheme()
_ = databasesv1alpha1.AddToScheme(scheme)
_ = appsv1.AddToScheme(scheme)
// Initialize fake client with a new MySQLCluster
cluster := &databasesv1alpha1.MySQLCluster{...}
fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cluster).Build()
reconciler := &MySQLClusterReconciler{Client: fakeClient, Scheme: scheme}
req := ctrl.Request{NamespacedName: types.NamespacedName{Name: cluster.Name, Namespace: "default"}}
_, err := reconciler.Reconcile(context.Background(), req)
require.NoError(t, err)
// Expect a StatefulSet to be created
var createdSts appsv1.StatefulSet
err = fakeClient.Get(context.Background(), types.NamespacedName{Name: cluster.Name, Namespace: "default"}, &createdSts)
require.NoError(t, err)
assert.Equal(t, int32(3), *createdSts.Spec.Replicas)
}
4.2 Integration Testing with Kind or k3s
- Kind (Kubernetes in Docker): Spin up a local cluster for end-to-end tests.
# Create kind cluster
kind create cluster --name operator-test
# Load operator image
kind load docker-image yourorg/mysql-operator:v0.1.0 --name operator-test
# Deploy CRDs and operator
overlay/kustomize apply
# Create sample CR
kubectl apply -f config/samples/databases_v1alpha1_mysqlcluster.yaml
# Validate resources
kubectl get statefulsets
kubectl get pods
Automate this in CI pipelines—if resources are not created or Pods crash, tests fail.
4.3 E2E Tests with Helm and Minikube
For operators deployed via Helm charts, use Helm test hooks:
# Package chart
helm package charts/mysql-operator
# Install chart
git checkout v0.1.0
helm install mysql-operator ./mysql-operator-0.1.0.tgz --namespace test-operator
# Run tests
helm test mysql-operator --namespace test-operator
E2E tests might involve creating a MySQLCluster
CR and verifying a sample query against one of the MySQL instances.
5. Best Practices for Scalable Operators
5.1 Versioning and CRD Evolution
-
CRD Versioning (v1alpha1 → v1beta1 → v1)
- Maintain backward-compatible schema changes: add new optional fields rather than removing or renaming.
- Provide conversion webhooks to migrate old CR versions to new versions if breaking changes occur.
-
Deprecate Carefully
- Mark outdated fields or API versions as deprecated in the CRD schema.
- Withdraw support after adequate notice and documentation.
5.2 Idempotence and Error Handling
-
Idempotent Reconcile
- Reconcile logic must handle retries and partial failures gracefully. For example, if creating a StatefulSet fails due to RBAC issues, operator should retry or log an event rather than panic.
-
Exponential Backoff
- Use controller-runtime’s default rate limiter or configure custom backoff to avoid overwhelming the API server when continuous errors occur.
5.3 Managing Secrets and Credentials
-
Kubernetes Secrets
- Store database passwords or TLS certificates in Kubernetes Secrets. Reconcile code should mount Secrets into Pods or inject environment variables.
- Set
ownerReferences
on Secrets so garbage collection removes them when the CR is deleted.
-
External Secret Management
- For enhanced security, integrate with external vaults (e.g., HashiCorp Vault) and inject secrets via CSI drivers or sidecar containers.
5.4 High Availability and Scaling
-
Leader Election
- For HA, deploy multiple operator replicas with leader election enabled (default in controller-runtime). Only the elected leader runs Reconcile loops to avoid conflicts.
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
MetricsBindAddress: metricsAddr,
LeaderElection: true,
LeaderElectionID: "mysqlcluster-controller",
})
-
Horizontal Scaling
- If an operator manages thousands of CRs, consider splitting functionality into multiple controllers or using a workqueue partitioning strategy.
5.5 Observability and Metrics
-
Prometheus Metrics
- Expose metrics (Reconcile duration, queue length, failures) via Operator SDK’s metrics server.
- Label metrics with namespace, CR name, or controller version for filtering.
-
Logging and Events
- Use structured logging (
ctrl.Log.WithName("controllers").WithName("MySQLCluster")...
). - Emit Kubernetes events on significant state changes (e.g.,
kubectl describe mysqlcluster sample-cluster
shows events for provisioning or failures).
- Use structured logging (
Conclusion
Custom Kubernetes operators enable robust, self-healing management of stateful applications at scale. By leveraging CRDs, Reconcile loops, and finalizers, operators embody operational expertise and reduce manual intervention. SDKs—such as the Operator SDK and Kubebuilder—drastically cut boilerplate, allowing developers to focus on business logic. Rigorous testing—encompassing unit, integration, and E2E tests—ensures operators behave reliably under diverse scenarios.
Following best practices around CRD versioning, idempotence, secret management, high availability, and observability ensures that operators scale gracefully as the number of managed resources grows. Whether deploying a MySQL cluster, Redis cache, or bespoke data pipeline, building a custom operator transforms Kubernetes into an automated platform that maintains desired state without human toil.
References
- Operator Framework. (2021). Operator SDK Documentation.
- Kubebuilder. (2021). Kubebuilder Book.
- Kubernetessigs. (2022). Controller-Runtime GitHub Repository.
- CoreOS. (2018). Building Operators with the Operator SDK.
- HashiCorp. (2021). Terraform Kubernetes Provider.
- Google. (2021). Kind: Kubernetes in Docker.
- Heptio. (2019). Sonobuoy: Kubernetes Conformance Testing.
- Sigstore. (2023). Venafi and Cosign for Operator Image Signing.
- Prometheus. (2022). Client Go: Instrumenting Operators.
- CNCF. (2020). Certified Kubernetes Operator Best Practices.