Kubernetes Operators: Implementing Finalizers for Stateful Resources

12 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 Achilles' Heel of Stateful Automation: Orphaned Resources

As a senior engineer building on Kubernetes, you understand the power of the operator pattern. The reconciliation loop is a robust mechanism for enforcing a desired state. However, its standard behavior has a critical limitation when managing resources that exist outside the Kubernetes API: the deletion lifecycle. When a user executes kubectl delete my-resource, Kubernetes initiates a 'fire-and-forget' garbage collection process. For a stateless pod, this is perfect. For a Custom Resource (CR) managing a provisioned S3 bucket, a CloudSQL instance, or a third-party SaaS subscription, this is a recipe for resource leaks, security vulnerabilities, and escalating cloud bills.

The default deletion process simply removes the object from etcd. It has no intrinsic knowledge of the real-world resources your controller provisioned on its behalf. This leaves them orphaned. The solution is to seize control of the deletion process, and the idiomatic, Kubernetes-native way to do this is with finalizers.

This article is not an introduction to operators. It assumes you are familiar with Go, Kubebuilder or Operator SDK, and the basics of controllers and reconciliation. We will dive deep into the mechanics of finalizers, implementing a production-grade pattern to ensure our DatabaseInstance operator performs a clean, reliable teardown of its external resources before allowing its own CR to be garbage collected.


Understanding the Deletion Flow: `deletionTimestamp` and Finalizers

To master finalizers, you must first understand how Kubernetes signals and processes object deletion. It's a two-phase mechanism orchestrated by the API server.

  • Phase 1: The Deletion Request
  • A user or another controller issues a DELETE request for an object (e.g., kubectl delete databaseinstance my-db). The API server doesn't immediately remove the object. Instead, it updates the object's metadata, setting the metadata.deletionTimestamp to the current time. The object now exists in a terminating state.

  • Phase 2: Garbage Collection
  • The Kubernetes garbage collector identifies objects with a non-nil deletionTimestamp. Before it can permanently remove the object from etcd, it checks one crucial field: metadata.finalizers.

    * If metadata.finalizers is empty: The garbage collector proceeds, and the object is deleted.

    * If metadata.finalizers contains one or more strings: The garbage collector stops. It will not delete the object until the finalizers array is empty. This is the hook we exploit.

    A finalizer is simply a string identifier that your controller adds to a resource's metadata.finalizers list. By adding a finalizer, your controller is telling the Kubernetes API: "Do not permanently delete this object until I have given my explicit approval by removing this specific string from the list."

    This transforms the deletion process into a cooperative, stateful workflow:

  • Creation: Your controller reconciles a new CR and adds its unique finalizer (e.g., "db.example.com/finalizer") to the object.
  • Deletion Request: A user runs kubectl delete. The API server sets deletionTimestamp.
  • Finalization Reconciliation: Your controller's reconciliation loop is triggered. It sees that deletionTimestamp is set and recognizes this as a signal to perform cleanup actions.
  • External Cleanup: The controller executes its finalization logic—calling an external API to deprovision a database, delete a storage bucket, etc.
  • Approval: Upon successful cleanup, the controller removes its finalizer from the metadata.finalizers list and updates the object.
  • Garbage Collection: The API server's garbage collector sees the object still has deletionTimestamp set, but now its finalizers list is empty. It proceeds to permanently delete the CR from etcd.
  • Practical Implementation: The `DatabaseInstance` Operator

    Let's build an operator that manages a DatabaseInstance CR. This CR represents a database provisioned in a fictional external cloud service. We'll use Kubebuilder to scaffold the project, but the core logic is identical for Operator SDK.

    1. Define the CRD

    Our API definition will include a spec for user input and a status to track the external resource's state.

    go
    // api/v1alpha1/databaseinstance_types.go
    package v1alpha1
    
    import (
    	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    )
    
    // DatabaseInstanceSpec defines the desired state of DatabaseInstance
    type DatabaseInstanceSpec struct {
    	// Engine specifies the database engine (e.g., "postgres", "mysql").
    	Engine string `json:"engine"`
    	// SizeGB specifies the storage size in gigabytes.
    	SizeGB int `json:"sizeGB"`
    }
    
    // DatabaseInstanceStatus defines the observed state of DatabaseInstance
    type DatabaseInstanceStatus struct {
    	// InstanceID is the unique identifier of the provisioned database in the external system.
    	InstanceID string `json:"instanceID,omitempty"`
    	// Endpoint is the connection endpoint for the database.
    	Endpoint string `json:"endpoint,omitempty"`
    	// Conditions represent the latest available observations of an object's state.
    	Conditions []metav1.Condition `json:"conditions,omitempty"`
    }
    
    //+kubebuilder:object:root=true
    //+kubebuilder:subresource:status
    
    // DatabaseInstance is the Schema for the databaseinstances API
    type DatabaseInstance struct {
    	metav1.TypeMeta   `json:",inline"`
    	metav1.ObjectMeta `json:"metadata,omitempty"`
    
    	Spec   DatabaseInstanceSpec   `json:"spec,omitempty"`
    	Status DatabaseInstanceStatus `json:"status,omitempty"`
    }
    
    //+kubebuilder:object:root=true
    
    // DatabaseInstanceList contains a list of DatabaseInstance
    type DatabaseInstanceList struct {
    	metav1.TypeMeta `json:",inline"`
    	metav1.ListMeta `json:"metadata,omitempty"`
    	Items           []DatabaseInstance `json:"items"`
    }
    
    func init() {
    	SchemeBuilder.Register(&DatabaseInstance{}, &DatabaseInstanceList{})
    }

    2. The Controller's Reconcile Logic

    This is where the core implementation resides. We will structure our Reconcile function to explicitly handle the deletion-aware workflow.

    First, let's define our finalizer's name as a constant.

    go
    // internal/controller/databaseinstance_controller.go
    const databaseInstanceFinalizer = "db.example.com/finalizer"

    Now, the Reconcile function. We use controller-runtime's helpers, which significantly simplify finalizer management.

    go
    // internal/controller/databaseinstance_controller.go
    import (
    	"context"
    
    	"k8s.io/apimachinery/pkg/runtime"
    	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/your-org/db-operator/api/v1alpha1"
    )
    
    func (r *DatabaseInstanceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    	logger := log.FromContext(ctx)
    
    	// 1. Fetch the DatabaseInstance resource
    	dbInstance := &dbv1alpha1.DatabaseInstance{}
    	if err := r.Get(ctx, req.NamespacedName, dbInstance); err != nil {
    		if client.IgnoreNotFound(err) != nil {
    			logger.Error(err, "unable to fetch DatabaseInstance")
    			return ctrl.Result{}, err
    		}
    		// Object was not found, probably already deleted. Nothing to do.
    		logger.Info("DatabaseInstance resource not found. Ignoring since object must be deleted.")
    		return ctrl.Result{}, nil
    	}
    
    	// 2. Examine the deletion timestamp to determine if the object is under deletion.
    	if dbInstance.ObjectMeta.DeletionTimestamp.IsZero() {
    		// The object is not being deleted, so we add our finalizer if it does not exist yet.
    		// This ensures that the deletion logic is triggered.
    		if !controllerutil.ContainsFinalizer(dbInstance, databaseInstanceFinalizer) {
    			logger.Info("Adding finalizer for DatabaseInstance")
    			controllerutil.AddFinalizer(dbInstance, databaseInstanceFinalizer)
    			if err := r.Update(ctx, dbInstance); err != nil {
    				return ctrl.Result{}, err
    			}
    		}
    	} else {
    		// The object is being deleted
    		if controllerutil.ContainsFinalizer(dbInstance, databaseInstanceFinalizer) {
    			logger.Info("Performing finalization for DatabaseInstance")
    
    			// Our finalizer is present, so we need to run our external resource cleanup logic.
    			if err := r.finalizeExternalDB(ctx, dbInstance); err != nil {
    				// If the cleanup fails, we don't remove the finalizer so we can retry on the next reconciliation.
    				logger.Error(err, "Failed to finalize external database")
    				return ctrl.Result{}, err
    			}
    
    			// Cleanup was successful, so we can remove our finalizer.
    			logger.Info("External database finalized successfully. Removing finalizer.")
    			controllerutil.RemoveFinalizer(dbInstance, databaseInstanceFinalizer)
    			if err := r.Update(ctx, dbInstance); err != nil {
    				return ctrl.Result{}, err
    			}
    		}
    
    		// Stop reconciliation as the item is being deleted
    		return ctrl.Result{}, nil
    	}
    
    	// 3. This is the main reconciliation logic for CREATION and UPDATES.
    	// If we get here, the object is not being deleted and our finalizer is in place.
    
    	// For this example, we'll mock the external client interaction.
    	externalID := dbInstance.Status.InstanceID
    	if externalID == "" {
    		logger.Info("Provisioning new external database")
    		instanceID, endpoint, err := r.ExternalClient.Provision(ctx, dbInstance.Spec.Engine, dbInstance.Spec.SizeGB)
    		if err != nil {
    			logger.Error(err, "Failed to provision external database")
    			// Update status with a failure condition
    			return ctrl.Result{}, err // Requeue on error
    		}
    
    		// Update the status with the new instance details
    		dbInstance.Status.InstanceID = instanceID
    		dbInstance.Status.Endpoint = endpoint
    		if err := r.Status().Update(ctx, dbInstance); err != nil {
    			logger.Error(err, "Failed to update DatabaseInstance status")
    			return ctrl.Result{}, err
    		}
    		logger.Info("Successfully provisioned external database", "InstanceID", instanceID, "Endpoint", endpoint)
    	} else {
    		// Here you would add logic to handle updates, e.g., resizing the database.
    		logger.Info("External database already provisioned. Skipping.", "InstanceID", externalID)
    	}
    
    	return ctrl.Result{}, nil
    }

    3. The Finalization Logic Deep Dive

    The finalizeExternalDB function is the heart of our teardown process. It must be idempotent, meaning it can be safely executed multiple times without adverse effects. This is critical because a reconciliation might fail after the external deletion but before the finalizer is removed, causing the logic to run again.

    go
    // internal/controller/databaseinstance_controller.go
    
    // For demonstration, we'll create a mock external client interface.
    // In a real project, this would be in its own package.
    
    type MockExternalClient struct{}
    
    func (c *MockExternalClient) Provision(ctx context.Context, engine string, size int) (string, string, error) {
    	// Mock provisioning logic
    	instanceID := "ext-" + uuid.New().String()
    	endpoint := instanceID + ".db.example.com"
    	return instanceID, endpoint, nil
    }
    
    func (c *MockExternalClient) Deprovision(ctx context.Context, instanceID string) error {
    	// Mock deprovisioning logic
    	// This is where you would call the cloud provider's API.
    	log.FromContext(ctx).Info("Deprovisioning called for instance", "InstanceID", instanceID)
    	return nil
    }
    
    // ... inside the Reconciler struct ...
    // ExternalClient ExternalDBClientInterface
    
    func (r *DatabaseInstanceReconciler) finalizeExternalDB(ctx context.Context, dbInstance *dbv1alpha1.DatabaseInstance) error {
    	logger := log.FromContext(ctx)
    
    	// The InstanceID should be in the status. If it's not, the external resource
    	// was likely never created, so we can consider cleanup successful.
    	if dbInstance.Status.InstanceID == "" {
    		logger.Info("No external instance ID found in status. Assuming it was never created or already cleaned up.")
    		return nil
    	}
    
    	logger.Info("Starting deprovisioning of external database", "InstanceID", dbInstance.Status.InstanceID)
    
    	err := r.ExternalClient.Deprovision(ctx, dbInstance.Status.InstanceID)
    	if err != nil {
    		// Production-grade check: if the error indicates the resource is already gone,
    		// we can treat it as a success to ensure idempotency.
    		// For example, checking for a 404 Not Found error from a cloud API.
    		// if isNotFoundError(err) {
    		// 	   logger.Info("External resource not found. Assuming already deprovisioned.")
    		// 	   return nil
    		// }
    
    		// For any other error, we must return it to trigger a retry.
    		return fmt.Errorf("failed to deprovision external database %s: %w", dbInstance.Status.InstanceID, err)
    	}
    
    	logger.Info("Successfully deprovisioned external database", "InstanceID", dbInstance.Status.InstanceID)
    
    	// Clear the InstanceID from the status as a final confirmation.
    	dbInstance.Status.InstanceID = ""
    	return r.Status().Update(ctx, dbInstance)
    }

    Advanced Edge Cases and Production Patterns

    Simply implementing the happy path is insufficient for a production system. Senior engineers must anticipate and handle failure modes.

    1. The Stuck Finalizer

    Problem: What happens if your finalization logic has a persistent bug, or the external API is down for an extended period? The controller will continuously fail to remove the finalizer. The CR will be stuck in a Terminating state indefinitely. A user running kubectl get sees the resource, but it's unusable and cannot be deleted.

    Solution & Mitigation:

    * Robust Error Handling: Your finalization logic must be rock-solid. Implement exponential backoff by returning ctrl.Result{RequeueAfter: ...} instead of a raw error for transient issues (like API rate limiting). This prevents the controller from hammering a failing endpoint.

    * Alerting: Monitor for CRs that have been in a Terminating state for an excessive amount of time (e.g., > 1 hour). This is a strong signal that manual intervention is required.

    * Manual Intervention (The Break-Glass Procedure): An administrator with cluster-admin privileges can forcefully remove the finalizer. This is a dangerous operation as it will orphan the external resource.

    bash
        # DANGER: This will orphan the external resource if the controller hasn't cleaned it up.
        kubectl patch databaseinstance my-db --type json -p='[{"op": "remove", "path": "/metadata/finalizers"}]'

    This procedure should be documented in your operator's runbook and used only as a last resort after manually verifying the state of the external resource.

    2. Controller Crashes and Restarts

    Problem: What if the controller pod crashes or is restarted in the middle of the finalization process? For example, it successfully calls the external API to delete the database but crashes before it can remove the finalizer from the CR.

    Solution: This is precisely why idempotency is non-negotiable. When the controller restarts, it will reconcile the object again. It will see the deletionTimestamp and its finalizer, and it will re-run the finalizeExternalDB function. Your external client logic must gracefully handle a request to delete a resource that has already been deleted. Most cloud provider APIs do this naturally (e.g., returning a 404 Not Found, which you should treat as a success in this context).

    go
    // In your external client library
    func (c *RealCloudClient) Deprovision(ctx context.Context, instanceID string) error {
        resp, err := c.sdk.DeleteDatabase(instanceID)
        if err != nil {
            // Cast the error to the provider's specific error type
            if apiErr, ok := err.(awserr.Error); ok {
                if apiErr.Code() == "DBInstanceNotFound" {
                    // This is not an error for our finalizer logic.
                    return nil 
                }
            }
            return err // Return all other errors
        }
        return nil
    }

    3. Handling Multiple Finalizers

    Problem: It's possible for multiple controllers to be interested in the same object. For example, one operator manages the database provisioning, while another manages backup policies for that same DatabaseInstance CR.

    Solution: Each controller must add and manage its own, uniquely named finalizer.

    * db.example.com/finalizer (from our provisioning operator)

    * backup.example.com/finalizer (from the backup operator)

    When kubectl delete is called, both controllers will see the deletionTimestamp. Each will run its own independent cleanup logic. The provisioning controller will deprovision the database and remove its finalizer. The backup controller will delete the backup jobs and archives and then remove its finalizer. The Kubernetes garbage collector will only delete the CR after both finalizers have been removed and the metadata.finalizers array is empty.

    Conclusion: Beyond Basic Reconciliation

    Finalizers are not an optional extra for operators managing stateful, external systems; they are a fundamental requirement for robust, production-ready automation. By taking control of the object lifecycle, you bridge the gap between the Kubernetes API and the outside world, preventing resource leaks and ensuring your automation is reliable from creation to graceful deletion.

    The pattern is simple in concept but requires meticulous implementation. Your finalization logic must be idempotent, your error handling must account for external system failures, and you must have operational procedures for edge cases like stuck finalizers. Mastering this pattern elevates your operator from a simple desired-state enforcer to a true lifecycle management engine, a hallmark of advanced Kubernetes engineering.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles