Advanced Kubernetes Operators: Idempotent Finalizers for Stateful Cleanup

13 min read
Goh Ling Yong
Technology enthusiast and software architect specializing in AI-driven development tools and modern software engineering practices. Passionate about the intersection of artificial intelligence and human creativity in building tomorrow's digital solutions.

The Deletion Fallacy in Stateful Kubernetes Automation

In a stateless world, Kubernetes' default garbage collection is a masterpiece of declarative design. You kubectl delete deployment my-app, and the controllers dutifully terminate pods, remove ReplicaSets, and clean up associated resources. This works flawlessly because the resources are self-contained within the cluster's state.

However, the moment an operator manages a resource with an external dependency—an AWS RDS instance, a GCP Cloud Storage bucket, a DNS record in Cloudflare—this model shatters. A standard DELETE API call on your Custom Resource (CR) triggers a cascade of deletion within Kubernetes, but it has no inherent mechanism to communicate with the outside world. The CR vanishes from etcd, and your operator's reconciliation loop, which triggers on changes to that CR, no longer has a resource to act upon. The result is an orphaned external resource: a running database, a bucket full of data, a live DNS entry, all incurring costs and posing potential security risks.

PreStop lifecycle hooks on pods are not a solution. They are designed for graceful pod shutdown, not for orchestrating the teardown of long-lived, independent infrastructure. The pod running your operator might be terminated for reasons entirely unrelated to the deletion of a specific CR it manages.

This is where the finalizer pattern becomes not just a best practice, but a fundamental requirement for building robust, stateful operators. A finalizer is a mechanism that allows your controller to intercept the deletion process, execute custom cleanup logic, and only then permit Kubernetes to complete the garbage collection.

This article will guide you through the implementation of a production-grade, idempotent finalizer within a Go-based operator built with Kubebuilder. We won't just cover the happy path; we will dissect the edge cases, failure modes, and observability patterns required for production systems.

Anatomy of a Finalizer

A finalizer is deceptively simple: it's merely a string added to the metadata.finalizers array of a Kubernetes object.

yaml
apiVersion: db.my-company.com/v1alpha1
kind: DatabaseClaim
metadata:
  name: user-service-db
  finalizers:
    - db.my-company.com/finalizer
# ... spec

Its power lies in how the Kubernetes API server treats it. When an object with one or more finalizers is deleted:

  • The API server receives the DELETE request.
  • Instead of immediately deleting the object from etcd, it performs a special kind of update: it sets the metadata.deletionTimestamp field to the current time.
  • The object remains in the API server, but is now in a "terminating" state. Any GET request will still return the object, but with this timestamp present.
  • The API server will not remove the object from etcd as long as the metadata.finalizers array is not empty.
  • This behavior transforms the deletion process from an instantaneous event into an observable state. It provides a hook for your controller to act. Your operator's reconciliation loop will be triggered by this update, see the deletionTimestamp, and recognize that it's time to perform cleanup.

    Once your controller successfully cleans up the external resources, its final responsibility is to make an UPDATE call to the object to remove its specific finalizer string from the array. If your finalizer was the last one in the list, the Kubernetes garbage collector is now free to complete its work and permanently delete the object.

    Building the `DatabaseClaim` Operator

    To demonstrate this pattern, we'll build an operator that manages a DatabaseClaim CRD. The operator's responsibilities are:

  • On Creation: When a DatabaseClaim CR is created, it simulates provisioning a database in an external service and stores the external ID in the CR's .status field.
  • On Deletion: When a DatabaseClaim is deleted, it uses a finalizer to ensure it de-provisions the database from the external service before the CR is removed from the cluster.
  • The CRD Definition

    First, let's define our DatabaseClaim resource in Go using Kubebuilder's conventions. This file would typically be located at api/v1alpha1/databaseclaim_types.go.

    go
    package v1alpha1
    
    import (
    	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    )
    
    // DatabaseClaimSpec defines the desired state of DatabaseClaim
    type DatabaseClaimSpec struct {
    	// DBName is the name of the database to be provisioned.
    	DBName string `json:"dbName"`
    	// Region specifies the cloud region for the database.
    	Region string `json:"region"`
    }
    
    // DatabaseClaimStatus defines the observed state of DatabaseClaim
    type DatabaseClaimStatus struct {
    	// ExternalID is the identifier of the database in the external system.
    	ExternalID string `json:"externalID,omitempty"`
    	// Provisioned is true once the database has been provisioned.
    	Provisioned bool `json:"provisioned,omitempty"`
    	// Conditions represent the latest available observations of the DatabaseClaim's state.
    	Conditions []metav1.Condition `json:"conditions,omitempty"`
    }
    
    //+kubebuilder:object:root=true
    //+kubebuilder:subresource:status
    //+kubebuilder:printcolumn:name="DBName",type="string",JSONPath=".spec.dbName"
    //+kubebuilder:printcolumn:name="Provisioned",type="boolean",JSONPath=".status.provisioned"
    //+kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
    
    // DatabaseClaim is the Schema for the databaseclaims API
    type DatabaseClaim struct {
    	metav1.TypeMeta   `json:",inline"`
    	metav1.ObjectMeta `json:"metadata,omitempty"`
    
    	Spec   DatabaseClaimSpec   `json:"spec,omitempty"`
    	Status DatabaseClaimStatus `json:"status,omitempty"`
    }
    
    //+kubebuilder:object:root=true
    
    // DatabaseClaimList contains a list of DatabaseClaim
    type DatabaseClaimList struct {
    	metav1.TypeMeta `json:",inline"`
    	metav1.ListMeta `json:"metadata,omitempty"`
    	Items           []DatabaseClaim `json:"items"`
    }
    
    func init() {
    	SchemeBuilder.Register(&DatabaseClaim{}, &DatabaseClaimList{})
    }

    This is a standard CRD definition. The key fields for our logic will be metadata.finalizers (inherited from metav1.ObjectMeta) and status.ExternalID.

    The Core Reconciliation Loop with Finalizer Logic

    Now we'll implement the controller logic in controllers/databaseclaim_controller.go. The core of the pattern resides within the Reconcile method.

    We'll define our finalizer name as a constant for consistency.

    go
    // controllers/databaseclaim_controller.go
    
    const databaseClaimFinalizer = "db.my-company.com/finalizer"
    
    // ... (imports and Reconciler struct definition)
    
    func (r *DatabaseClaimReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    	log := log.FromContext(ctx)
    
    	// 1. Fetch the DatabaseClaim instance
    	claim := &dbv1alpha1.DatabaseClaim{}
    	err := r.Get(ctx, req.NamespacedName, claim)
    	if err != nil {
    		if errors.IsNotFound(err) {
    			// Object not found, probably deleted. Nothing to do.
    			log.Info("DatabaseClaim resource not found. Ignoring since object must be deleted")
    			return ctrl.Result{}, nil
    		}
    		log.Error(err, "Failed to get DatabaseClaim")
    		return ctrl.Result{}, err
    	}
    
    	// 2. Examine deletion timestamp and handle finalizer logic
    	isBeingDeleted := claim.GetDeletionTimestamp() != nil
    	if isBeingDeleted {
    		if controllerutil.ContainsFinalizer(claim, databaseClaimFinalizer) {
    			// Our finalizer is present, so let's handle external dependency cleanup.
    			log.Info("Performing finalizer cleanup for DatabaseClaim")
    			if err := r.handleDeletion(ctx, claim); err != nil {
    				// If cleanup fails, we don't remove the finalizer so we can retry on the next reconciliation.
    				log.Error(err, "Finalizer cleanup failed")
    				// Requeue with exponential backoff.
    				return ctrl.Result{}, err
    			}
    
    			// Cleanup was successful. Remove our finalizer.
    			log.Info("Finalizer cleanup successful, removing finalizer")
    			controllerutil.RemoveFinalizer(claim, databaseClaimFinalizer)
    			if err := r.Update(ctx, claim); err != nil {
    				return ctrl.Result{}, err
    			}
    		}
    		// Stop reconciliation as the item is being deleted
    		return ctrl.Result{}, nil
    	}
    
    	// 3. Add finalizer for new resources
    	if !controllerutil.ContainsFinalizer(claim, databaseClaimFinalizer) {
    		log.Info("Adding finalizer for DatabaseClaim")
    		controllerutil.AddFinalizer(claim, databaseClaimFinalizer)
    		if err := r.Update(ctx, claim); err != nil {
    			return ctrl.Result{}, err
    		}
    	}
    
    	// 4. Main reconciliation logic (provisioning)
    	if !claim.Status.Provisioned {
    		log.Info("Provisioning external database")
            // This is a placeholder for your actual provisioning logic
    		externalID, err := r.ExternalDBService.Provision(claim.Spec.DBName, claim.Spec.Region)
    		if err != nil {
    			log.Error(err, "Failed to provision external database")
    			// Update status with a failure condition
    			return ctrl.Result{}, err
    		}
    
    		claim.Status.ExternalID = externalID
    		claim.Status.Provisioned = true
    		if err := r.Status().Update(ctx, claim); err != nil {
    			log.Error(err, "Failed to update DatabaseClaim status")
    			return ctrl.Result{}, err
    		}
    		log.Info("External database provisioned successfully", "ExternalID", externalID)
    	}
    
    	return ctrl.Result{}, nil
    }

    Let's break down the numbered sections:

  • Fetch Instance: Standard controller boilerplate to fetch the CR we need to work on.
  • Deletion & Finalizer Logic: This is the core of the pattern. We first check if GetDeletionTimestamp() is non-nil.
  • - If it is, the user has requested deletion. We then check if our finalizer is in the list.

    - If it is, we call our cleanup logic (handleDeletion). If cleanup fails, we return an error, which causes controller-runtime to requeue the request. Critically, we do not remove the finalizer.

    - If cleanup succeeds, we use controllerutil.RemoveFinalizer and update the object. This is the signal to Kubernetes that we are done. The reconciliation stops here.

  • Add Finalizer: If the object is not being deleted, we ensure our finalizer is present. If it's not, we add it and update the object. This is typically one of the first things that happens in an object's lifecycle. It's a critical step to ensure that we can intercept a future deletion request.
  • Main Logic: This is the business logic of your operator—provisioning the database, configuring it, etc. This only runs if the object is not being deleted.
  • Implementing the Idempotent Deletion Logic

    The handleDeletion function is where you interact with the external world. A critical property of this function must be idempotency. Reconciliation loops can run multiple times for the same event due to errors or unrelated cluster state changes. Your cleanup function must be safe to call repeatedly.

    go
    // A mock external service client
    type MockExternalDBService struct{}
    
    func (m *MockExternalDBService) Deprovision(externalID string) error {
    	log := log.Log.WithName("external-db-service")
    	if externalID == "" {
    		log.Info("Deprovision called with empty externalID, assuming already deprovisioned.")
    		return nil // Idempotency: already gone
    	}
    
    	log.Info("Simulating deprovisioning database", "ExternalID", externalID)
    
    	// Simulate a transient error 50% of the time
    	if rand.Float32() < 0.5 {
    		return fmt.Errorf("transient API error while deprovisioning %s", externalID)
    	}
    
    	log.Info("Successfully deprovisioned database", "ExternalID", externalID)
    	return nil
    }
    
    // ... In the DatabaseClaimReconciler struct ...
    // ExternalDBService ExternalDBServiceInterface
    
    func (r *DatabaseClaimReconciler) handleDeletion(ctx context.Context, claim *dbv1alpha1.DatabaseClaim) error {
    	log := log.FromContext(ctx)
    
    	if claim.Status.ExternalID == "" {
    		log.Info("DatabaseClaim has no ExternalID, nothing to clean up.")
    		return nil
    	}
    
    	log.Info("Deprovisioning external database", "ExternalID", claim.Status.ExternalID)
    	err := r.ExternalDBService.Deprovision(claim.Status.ExternalID)
    	if err != nil {
            // Here, you could check for specific error types. 
            // If the error indicates the resource is already gone (e.g., a 404), 
            // you should treat it as a success.
    		if isAlreadyGoneError(err) { // isAlreadyGoneError is a hypothetical function
                log.Info("External resource already deleted.")
                return nil
            }
    		return fmt.Errorf("failed to deprovision external database: %w", err)
    	}
    
    	return nil
    }

    Key points of this implementation:

  • Idempotency Check: The function first checks if ExternalID is even present. If not, there's nothing to do, and it returns nil (success). This prevents errors if the provisioning step never completed.
  • External API Call: It calls the (mock) external service to perform the deprovisioning.
  • Error Handling: If the external service returns an error, handleDeletion propagates it. The main Reconcile loop will catch this and requeue the request. The finalizer remains, and the DatabaseClaim object will be stuck in the Terminating state until the cleanup succeeds.
  • Handling "Not Found" Errors: A robust implementation would parse the error from the external API. If the error indicates the resource is already gone (e.g., an HTTP 404), that's a successful cleanup from our perspective. The function should return nil in this case to allow the finalizer to be removed.
  • Advanced Edge Cases and Production Patterns

    Implementing the basic finalizer loop is only half the battle. Production systems require resilience and observability.

    1. Stuck Finalizers and Manual Intervention

    Problem: What happens if your cleanup logic has a permanent bug, or the external API is down for an extended period? The resource will be stuck in the Terminating state indefinitely because the finalizer can never be removed by the operator.

    Solution: This is an operational issue. A cluster administrator must intervene. The finalizer can be removed manually using kubectl patch.

    bash
    # Get the current state of the finalizers
    kubectl get databaseclaim user-service-db -o jsonpath='{.metadata.finalizers}'
    
    # Patch the object to remove the finalizer
    # NOTE: This is a dangerous operation. Only do this if you have manually
    # confirmed the external resource is gone or can be safely orphaned.
    kubectl patch databaseclaim user-service-db --type json -p='[{"op": "remove", "path": "/metadata/finalizers/0"}]'

    Production Pattern: Your operator's documentation must include a section on how to identify and manually resolve stuck finalizers. You should also have monitoring in place to alert on resources that have been in a Terminating state for too long (e.g., > 1 hour).

    2. Exponential Backoff for Cleanup Retries

    Problem: If an external API is flaky or rate-limiting, retrying the cleanup every few seconds (the default controller-runtime requeue rate) can make the problem worse, a form of a denial-of-service attack on your dependency.

    Solution: Controller-runtime uses a workqueue that has exponential backoff built-in by default. When you return an error from Reconcile, the request is requeued and will be retried with increasing delays. You can tune these rates in your main.go when setting up the manager, but the defaults are generally sensible.

    For more explicit control, you can return a ctrl.Result{RequeueAfter: duration}. However, simply returning an error is the idiomatic way to handle this and leverage the default backoff behavior.

    3. Observability: Events and Metrics

    Problem: When a deletion is pending, how do you know what the operator is doing? kubectl describe shows the Terminating state, but provides no insight into the cleanup process.

    Solution: Use Kubernetes Events and Prometheus metrics.

    Events: Events are namespace-scoped objects that attach to other objects and provide a log of notable occurrences. They are invaluable for user-facing feedback.

    go
    // In your Reconciler struct, add a recorder
    type DatabaseClaimReconciler struct {
    	// ... other fields
    	recorder record.EventRecorder
    }
    
    // In your handleDeletion function:
    func (r *DatabaseClaimReconciler) handleDeletion(ctx context.Context, claim *dbv1alpha1.DatabaseClaim) error {
        // ...
    
    	err := r.ExternalDBService.Deprovision(claim.Status.ExternalID)
    	if err != nil {
            r.recorder.Eventf(claim, "Warning", "CleanupFailed", "Failed to deprovision external database: %v", err)
            return err
        }
    
        r.recorder.Eventf(claim, "Normal", "CleanupSuccessful", "Successfully deprovisioned external database %s", claim.Status.ExternalID)
        return nil
    }
    
    // In main.go, ensure the recorder is initialized and passed to the reconciler.

    Now, when you run kubectl describe databaseclaim user-service-db during a deletion, you will see these events, clearly indicating success or failure of the cleanup step.

    Metrics: For cluster-wide monitoring, use Prometheus metrics. You can expose metrics like:

  • databaseclaims_terminating_total: A gauge showing the number of DatabaseClaim resources currently in a terminating state.
  • databaseclaim_cleanup_errors_total: A counter that increments every time handleDeletion fails.
  • databaseclaim_cleanup_duration_seconds: A histogram to track how long the cleanup process takes.
  • These metrics allow you to build dashboards and alerts to proactively manage the health of your stateful workloads.

    Complete Controller Example

    Here is a more complete, runnable databaseclaim_controller.go incorporating these concepts.

    go
    package controllers
    
    import (
    	"context"
    	"fmt"
    
    	"k8s.io/apimachinery/pkg/api/errors"
    	"k8s.io/apimachinery/pkg/runtime"
    	"k8s.io/client-go/tools/record"
    	ctrl "sigs.k8s.io/controller-runtime"
    	"sigs.k8s.io/controller-runtime/pkg/client"
    	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
    	"sigs.k8s.io/controller-runtime/pkg/log"
    
    	dbv1alpha1 "github.com/my-org/mydb-operator/api/v1alpha1"
    )
    
    const databaseClaimFinalizer = "db.my-company.com/finalizer"
    
    // DatabaseClaimReconciler reconciles a DatabaseClaim object
    type DatabaseClaimReconciler struct {
    	client.Client
    	Scheme          *runtime.Scheme
    	EventRecorder   record.EventRecorder
    	ExternalDBService // Your interface for the external service
    }
    
    //+kubebuilder:rbac:groups=db.my-company.com,resources=databaseclaims,verbs=get;list;watch;create;update;patch;delete
    //+kubebuilder:rbac:groups=db.my-company.com,resources=databaseclaims/status,verbs=get;update;patch
    //+kubebuilder:rbac:groups=db.my-company.com,resources=databaseclaims/finalizers,verbs=update
    //+kubebuilder:rbac:groups="",resources=events,verbs=create;patch
    
    func (r *DatabaseClaimReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    	logger := log.FromContext(ctx)
    
    	claim := &dbv1alpha1.DatabaseClaim{}
    	if err := r.Get(ctx, req.NamespacedName, claim); err != nil {
    		if errors.IsNotFound(err) {
    			return ctrl.Result{}, nil
    		}
    		return ctrl.Result{}, err
    	}
    
    	if claim.ObjectMeta.DeletionTimestamp != nil {
    		if controllerutil.ContainsFinalizer(claim, databaseClaimFinalizer) {
    			logger.Info("Handling finalizer for DatabaseClaim")
    			if err := r.handleDeletion(ctx, claim); err != nil {
    				r.EventRecorder.Event(claim, "Warning", "CleanupFailed", fmt.Sprintf("Finalizer cleanup failed: %v", err))
    				return ctrl.Result{}, err
    			}
    
    			logger.Info("Removing finalizer after successful cleanup")
    			controllerutil.RemoveFinalizer(claim, databaseClaimFinalizer)
    			if err := r.Update(ctx, claim); err != nil {
    				return ctrl.Result{}, err
    			}
    			r.EventRecorder.Event(claim, "Normal", "FinalizerRemoved", "Successfully cleaned up and removed finalizer")
    		}
    		return ctrl.Result{}, nil
    	}
    
    	if !controllerutil.ContainsFinalizer(claim, databaseClaimFinalizer) {
    		logger.Info("Adding finalizer to DatabaseClaim")
    		controllerutil.AddFinalizer(claim, databaseClaimFinalizer)
    		if err := r.Update(ctx, claim); err != nil {
    			return ctrl.Result{}, err
    		}
    	}
    
    	// Main reconciliation logic goes here...
    
    	return ctrl.Result{}, nil
    }
    
    func (r *DatabaseClaimReconciler) handleDeletion(ctx context.Context, claim *dbv1alpha1.DatabaseClaim) error {
    	// Idempotent cleanup logic
    	if claim.Status.ExternalID != "" {
    		logger := log.FromContext(ctx)
    		logger.Info("Deprovisioning external resource", "ExternalID", claim.Status.ExternalID)
    		// err := r.ExternalDBService.Deprovision(claim.Status.ExternalID)
    		// if err != nil && !isNotFoundError(err) { 
    		//     return err
    		// }
    		logger.Info("Simulated deprovisioning successful")
    	}
    	return nil
    }
    
    // SetupWithManager sets up the controller with the Manager.
    func (r *DatabaseClaimReconciler) SetupWithManager(mgr ctrl.Manager) error {
    	return ctrl.NewControllerManagedBy(mgr).
    		For(&dbv1alpha1.DatabaseClaim{}).
    		Complete(r)
    }
    

    Conclusion

    The finalizer pattern is the cornerstone of reliable, stateful automation in Kubernetes. By treating deletion as a state managed by the reconciliation loop rather than an abrupt event, you can build operators that safely manage the entire lifecycle of resources, both inside and outside the cluster.

    A production-ready implementation goes beyond the basic loop. It demands idempotency in its cleanup logic, robust error handling that leverages the controller's requeue mechanism, and comprehensive observability through events and metrics. While manual intervention for stuck finalizers is a necessary escape hatch, a well-designed operator with proper monitoring should make it a rare exception. By mastering this pattern, you unlock the full potential of Kubernetes as a control plane for your entire infrastructure, not just your containerized applications.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles