Production-Ready Kubernetes Operators: The Finalizer Pattern in Go

15 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 Inevitable State Mismatch: Declarative Models vs. Imperative Actions

As a seasoned engineer working with Kubernetes, you understand its power lies in its declarative model. You define the desired state in a YAML manifest, and a controller works to make it so. This works beautifully for resources within the cluster's domain. However, the moment an operator needs to manage resources outside the Kubernetes API—a cloud database, a DNS entry, a SaaS subscription—this elegant model reveals a critical gap.

Consider a ManagedDatabase Custom Resource (CR). When a user executes kubectl delete manageddatabase my-db, the Kubernetes garbage collector swiftly removes the object from etcd. If your operator's reconciliation loop is triggered by this event, it receives a NotFound error when fetching the object. The CR's spec, which held the details needed for cleanup (like the cloud provider's database ID), is gone. The result is an orphaned, billable resource in your cloud account. This is the fundamental problem that finalizers are designed to solve.

A finalizer is a Kubernetes mechanism that allows controllers to hook into the pre-deletion lifecycle of an object. It's simply a list of string keys on an object's metadata. When an object with a non-empty finalizers list is deleted, the API server doesn't actually remove it. Instead, it sets the metadata.deletionTimestamp field and updates the object. The object now exists in a 'terminating' state, visible to controllers but hidden from most user-facing commands. It is now the controller's responsibility to perform its cleanup logic and, only upon successful completion, remove its finalizer key from the list. Once the finalizers list is empty, the Kubernetes garbage collector permanently deletes the object.

This article will guide you through the implementation of this pattern in a production context, using Go and the controller-runtime library. We will skip the basic kubebuilder scaffolding and dive straight into the nuanced logic of the reconciliation loop.


1. Defining the CRD with Status and Finalizer in Mind

Before we write the controller logic, our API definition must be robust. A production-grade CRD needs a comprehensive status subresource to provide visibility into the controller's actions. This is non-negotiable for debugging and user feedback.

Let's define a ManagedDatabase CRD. The spec describes the desired state, and the status reflects the actual, observed state of the external resource.

api/v1/manageddatabase_types.go

go
package v1

import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// ManagedDatabaseSpec defines the desired state of ManagedDatabase
type ManagedDatabaseSpec struct {
	// Engine specifies the database engine (e.g., "postgres", "mysql").
	// +kubebuilder:validation:Enum=postgres;mysql
	// +kubebuilder:validation:Required
	Engine string `json:"engine"`

	// Version specifies the engine version.
	// +kubebuilder:validation:Required
	Version string `json:"version"`

	// StorageGB specifies the allocated storage in gigabytes.
	// +kubebuilder:validation:Minimum=10
	StorageGB int `json:"storageGB"`
}

// ManagedDatabaseStatus defines the observed state of ManagedDatabase
type ManagedDatabaseStatus struct {
	// ExternalID holds the identifier of the database in the cloud provider.
	// +optional
	ExternalID string `json:"externalId,omitempty"`

	// Endpoint is the connection endpoint for the database.
	// +optional
	Endpoint string `json:"endpoint,omitempty"`

	// Phase indicates the current state of the resource.
	// e.g., "Creating", "Ready", "Deleting", "Failed"
	// +optional
	Phase string `json:"phase,omitempty"`

	// Conditions represent the latest available observations of the resource's state.
	// +optional
	// +patchMergeKey=type
	// +patchStrategy=merge
	Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"`
}

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
//+kubebuilder:printcolumn:name="Engine",type="string",JSONPath=".spec.engine"
//+kubebuilder:printcolumn:name="Version",type="string",JSONPath=".spec.version"
//+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.phase"

// ManagedDatabase is the Schema for the manageddatabases API
type ManagedDatabase struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   ManagedDatabaseSpec   `json:"spec,omitempty"`
	Status ManagedDatabaseStatus `json:"status,omitempty"`
}

//+kubebuilder:object:root=true

// ManagedDatabaseList contains a list of ManagedDatabase
type ManagedDatabaseList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata,omitempty"`
	Items           []ManagedDatabase `json:"items"`
}

func init() {
	SchemeBuilder.Register(&ManagedDatabase{}, &ManagedDatabaseList{})
}

Key elements here for our pattern:

* //+kubebuilder:subresource:status: This is critical. It tells the API server that the status field should be treated as a separate endpoint, preventing controllers from overwriting status changes when they only intend to modify the spec.

* Status.ExternalID: We need a place to store the ID of the resource we create externally. This is essential for the DELETE operation.

* Status.Phase and Status.Conditions: These provide rich, observable state to users running kubectl describe or kubectl get.


2. The Core Reconciliation Loop with Finalizer Logic

Now, let's construct the controller's Reconcile method. The logic must be structured to handle three distinct states for any given CR:

  • Creation/Update: The object exists and does not have a deletionTimestamp.
  • Deletion: The object exists but does have a deletionTimestamp.
  • Not Found: The object has been fully garbage collected (our finalizer is gone).
  • We'll define our finalizer's name as a constant for clarity and to avoid magic strings.

    internal/controller/manageddatabase_controller.go (Initial Structure)

    go
    package controller
    
    import (
    	"context"
    	"fmt"
    	"time"
    
    	// ... other imports
    	"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"
    
    	dbv1 "my-operator/api/v1"
    )
    
    const managedDatabaseFinalizer = "db.example.com/finalizer"
    
    // A mock external client
    type MockDBProviderClient struct{}
    
    func (c *MockDBProviderClient) CreateDatabase(spec dbv1.ManagedDatabaseSpec) (string, string, error) {
    	// In a real implementation, this would call a cloud provider API.
    	fmt.Printf("Creating database %s v%s with %dGB storage\n", spec.Engine, spec.Version, spec.StorageGB)
    	// Simulate API call latency
    	time.Sleep(2 * time.Second)
    	// Return a fake external ID and endpoint
    	return fmt.Sprintf("ext-%d", time.Now().UnixNano()), "some-db-endpoint.example.com", nil
    }
    
    func (c *MockDBProviderClient) DeleteDatabase(externalID string) error {
    	// In a real implementation, this would call a cloud provider API.
    	fmt.Printf("Deleting database with external ID: %s\n", externalID)
    	// Simulate API call latency
    	time.Sleep(2 * time.Second)
    	return nil
    }
    
    // ManagedDatabaseReconciler reconciles a ManagedDatabase object
    type ManagedDatabaseReconciler struct {
    	client.Client
    	Scheme   *runtime.Scheme
    	DBClient *MockDBProviderClient // Our mock client for external interactions
    }
    
    // ... RBAC comments ...
    
    func (r *ManagedDatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    	logger := log.FromContext(ctx)
    
    	// 1. Fetch the ManagedDatabase instance
    	dbInstance := &dbv1.ManagedDatabase{}
    	if err := r.Get(ctx, req.NamespacedName, dbInstance); err != nil {
    		if client.IgnoreNotFound(err) != nil {
    			logger.Error(err, "Failed to get ManagedDatabase")
    			return ctrl.Result{}, err
    		}
    		// Object not found, it must have been deleted. Stop reconciliation.
    		logger.Info("ManagedDatabase resource not found. Ignoring since object must be deleted.")
    		return ctrl.Result{}, nil
    	}
    
    	// The core logic will go here
    
    	return ctrl.Result{}, nil
    }
    
    // ... SetupWithManager function ...

    Now, let's implement the core logic within the Reconcile function. The flow is critical.

    internal/controller/manageddatabase_controller.go (Full Reconcile Logic)

    go
    // ... (imports and struct definitions from above)
    
    func (r *ManagedDatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    	logger := log.FromContext(ctx)
    
    	// 1. Fetch the ManagedDatabase instance
    	dbInstance := &dbv1.ManagedDatabase{}
    	if err := r.Get(ctx, req.NamespacedName, dbInstance); err != nil {
    		if client.IgnoreNotFound(err) != nil {
    			logger.Error(err, "Failed to get ManagedDatabase")
    			return ctrl.Result{}, err
    		}
    		logger.Info("ManagedDatabase resource not found. Ignoring since object must be deleted.")
    		return ctrl.Result{}, nil
    	}
    
    	// 2. Check if the object is being deleted
    	isMarkedForDeletion := dbInstance.GetDeletionTimestamp() != nil
    	if isMarkedForDeletion {
    		if controllerutil.ContainsFinalizer(dbInstance, managedDatabaseFinalizer) {
    			logger.Info("Performing Finalizer Operations for ManagedDatabase before deletion")
    
    			// Perform all cleanup actions
    			if err := r.finalizeDatabase(ctx, dbInstance); err != nil {
    				logger.Error(err, "Failed to finalize database")
    				// If finalize fails, we return an error so we can retry.
    				return ctrl.Result{}, err
    			}
    
    			// Once cleanup is complete, remove the finalizer.
    			logger.Info("Removing Finalizer for ManagedDatabase after successful cleanup")
    			controllerutil.RemoveFinalizer(dbInstance, managedDatabaseFinalizer)
    			if err := r.Update(ctx, dbInstance); err != nil {
    				logger.Error(err, "Failed to remove finalizer")
    				return ctrl.Result{}, err
    			}
    		}
    		// Stop reconciliation as the item is being deleted
    		return ctrl.Result{}, nil
    	}
    
    	// 3. Add finalizer for new objects
    	if !controllerutil.ContainsFinalizer(dbInstance, managedDatabaseFinalizer) {
    		logger.Info("Adding Finalizer for ManagedDatabase")
    		controllerutil.AddFinalizer(dbInstance, managedDatabaseFinalizer)
    		if err := r.Update(ctx, dbInstance); err != nil {
    			logger.Error(err, "Failed to add finalizer")
    			return ctrl.Result{}, err
    		}
            // Return here to let the update propagate and re-reconcile.
            return ctrl.Result{Requeue: true}, nil
    	}
    
    	// 4. Main reconciliation logic (Create/Update)
    
        // Check if the external resource already exists, if not create it
        if dbInstance.Status.ExternalID == "" {
            logger.Info("Creating external database")
            dbInstance.Status.Phase = "Creating"
            if err := r.Status().Update(ctx, dbInstance); err != nil {
                logger.Error(err, "Failed to update ManagedDatabase status to Creating")
                return ctrl.Result{}, err
            }
    
            externalID, endpoint, err := r.DBClient.CreateDatabase(dbInstance.Spec)
            if err != nil {
                logger.Error(err, "Failed to create external database")
                dbInstance.Status.Phase = "Failed"
                // Update status and do not requeue on permanent failure.
                // For transient errors, you might return the error to requeue.
                _ = r.Status().Update(ctx, dbInstance)
                return ctrl.Result{}, nil 
            }
    
            // Update status with the new external resource info
            dbInstance.Status.ExternalID = externalID
            dbInstance.Status.Endpoint = endpoint
            dbInstance.Status.Phase = "Ready"
            if err := r.Status().Update(ctx, dbInstance); err != nil {
                logger.Error(err, "Failed to update ManagedDatabase status after creation")
                return ctrl.Result{}, err
            }
            logger.Info("Successfully created external database", "ExternalID", externalID)
            return ctrl.Result{}, nil
        }
    
        // TODO: Implement update logic if spec changes. For this example, we assume immutability.
        logger.Info("Reconciliation complete, external database already exists.")
    	return ctrl.Result{}, nil
    }
    
    // finalizeDatabase performs the cleanup logic.
    func (r *ManagedDatabaseReconciler) finalizeDatabase(ctx context.Context, dbInstance *dbv1.ManagedDatabase) error {
        logger := log.FromContext(ctx)
    
        // Check if there's an external ID to use for deletion
        if dbInstance.Status.ExternalID == "" {
            logger.Info("No external database to delete, skipping finalization.")
            return nil
        }
    
        logger.Info("Deleting external database", "ExternalID", dbInstance.Status.ExternalID)
        err := r.DBClient.DeleteDatabase(dbInstance.Status.ExternalID)
        if err != nil {
            // This is a critical point. If the external deletion fails, we must return an error.
            // This will cause the reconciliation to be re-queued, and we'll try to delete again later.
            // The finalizer will NOT be removed, preventing the CR from being deleted.
            return fmt.Errorf("failed to delete external database %s: %w", dbInstance.Status.ExternalID, err)
        }
    
        logger.Info("Successfully deleted external database")
        return nil
    }
    
    func (r *ManagedDatabaseReconciler) SetupWithManager(mgr ctrl.Manager) error {
    	// Initialize the mock client here
    	r.DBClient = &MockDBProviderClient{}
    
    	return ctrl.NewControllerManagedBy(mgr).
    		For(&dbv1.ManagedDatabase{}).
    		Complete(r)
    }

    Dissecting the Logic:

  • Fetch Instance: Standard boilerplate. We use client.IgnoreNotFound because a request for a deleted object is expected.
  • Check Deletion Timestamp: This is the primary fork in our logic. dbInstance.GetDeletionTimestamp() != nil is our signal to switch from reconciliation to finalization.
  • Finalization Block: If the object is being deleted and our finalizer is present, we call finalizeDatabase. If that function returns an error, we immediately return the error to the controller-runtime, which will re-queue the request with exponential backoff. If cleanup is successful, we use controllerutil.RemoveFinalizer and r.Update() to persist this change. The object will then be garbage collected.
  • Add Finalizer Block: If the object is not being deleted and our finalizer is missing, it must be a new or recently restored object. We use controllerutil.AddFinalizer and immediately update and return with Requeue: true. This is a best practice: perform one logical action per reconcile loop. The update triggers a new reconciliation, and on the next pass, the finalizer will be present, and we'll proceed to the main logic.
  • Main Reconciliation: This block only executes if the object is alive and has our finalizer. Here, we implement our idempotent creation logic. We check Status.ExternalID to see if we've already created the resource. If not, we call our external client, and upon success, we update the status subresource. Note the use of r.Status().Update(), which is crucial when status subresources are enabled.

  • 3. Advanced Edge Cases and Production Considerations

    The code above provides a solid foundation, but production environments introduce complexity. Senior engineers must anticipate and handle these scenarios.

    Edge Case 1: The Stuck Finalizer

    Problem: What happens if finalizeDatabase consistently fails? For example, the cloud provider API is down for an extended period, or the credentials used by the operator have expired.

    Impact: The ManagedDatabase CR will be stuck in the Terminating state indefinitely. kubectl delete will hang, and the namespace cannot be deleted. This is a common operational headache.

    Solution & Mitigation:

  • Robust Error Handling: In finalizeDatabase, differentiate between transient and permanent errors. If the cloud provider returns a 404 Not Found for the external resource ID, it means the resource is already gone. In this case, you should consider the cleanup successful and return nil to allow the finalizer to be removed.
  • Status Updates During Deletion: Update the status to Phase: "Deleting" and add a Condition with the reason for the failure. This gives operators visibility into why the object is stuck.
  • Manual Intervention: The ultimate fallback is manual intervention. An administrator must diagnose the issue (e.g., fix credentials), or, if the external resource has been manually deleted, they can force the removal of the finalizer with kubectl patch manageddatabase my-db -p '{"metadata":{"finalizers":[]}}' --type=merge. Your operator's documentation should cover this procedure.
  • Edge Case 2: Controller Restart During Finalization

    Problem: The controller successfully calls the cloud provider to delete the database but crashes before it can remove the finalizer from the CR.

    Impact: On restart, the controller will receive the same Terminating object and call finalizeDatabase again.

    Solution: Idempotency. Your external deletion logic must be able to handle being called on an already-deleted resource without erroring. Most cloud provider DELETE APIs are idempotent by nature (deleting a non-existent resource often returns a success or a specific NotFound error). Your code must handle this gracefully.

    Example finalizeDatabase with Idempotency Check:

    go
    func (r *MockDBProviderClient) DeleteDatabase(externalID string) error {
        // ... pseudo-code for a real API call
        // response, err := cloudAPI.Delete(externalID)
        // if err != nil {
        //     if IsNotFoundError(err) {
        //         // The resource is already gone. This is a success from our perspective.
        //         return nil
        //     }
        //     return err
        // }
        // return nil
    
        fmt.Printf("Deleting database with external ID: %s\n", externalID)
    	time.Sleep(2 * time.Second)
    	return nil // Mock is already idempotent
    }

    Performance Consideration: Requeue Strategy

    Problem: A transient failure in the finalization step (e.g., a network blip) causes an immediate requeue. With controller-runtime's exponential backoff, this can lead to many rapid-fire reconciliation attempts, potentially hitting API rate limits.

    Analysis: Returning a raw error is the correct approach for unexpected failures. However, for known transient issues, a more controlled requeue might be better. For example, if a cloud API is known to have eventual consistency issues, you might want to wait a fixed period.

    Implementation:

    Instead of just return ctrl.Result{}, err, you can be more specific.

    go
    if err := r.finalizeDatabase(ctx, dbInstance); err != nil {
        logger.Error(err, "Failed to finalize database")
        // If we know it's a rate-limiting error, we can choose a longer, fixed requeue time.
        if IsRateLimitError(err) {
            logger.Info("Hit rate limit, requeueing after 1 minute")
            return ctrl.Result{RequeueAfter: time.Minute}, nil
        }
        // For other errors, use the default exponential backoff.
        return ctrl.Result{}, err
    }

    This gives you fine-grained control over the retry behavior, a hallmark of a production-ready system.


    4. Observing the Full Lifecycle

    Let's trace the lifecycle of a ManagedDatabase CR with our controller running.

    1. Creation:

    kubectl apply -f config/samples/db_v1_manageddatabase.yaml

    * Reconcile 1: Object is found, deletionTimestamp is nil, finalizer is missing. Controller adds db.example.com/finalizer and updates the object. Returns Requeue: true.

    * Reconcile 2: Object is found, finalizer is present. Status.ExternalID is empty. Controller sets Status.Phase to "Creating", updates status. Calls CreateDatabase.

    * Reconcile 3 (after status update): CreateDatabase succeeds. Controller updates Status.ExternalID, Status.Endpoint, and sets Status.Phase to "Ready". Updates status. Reconciliation is now stable.

    2. Deletion:

    kubectl delete manageddatabase my-db-sample

    * API server receives the delete request. It sees the finalizer, so it sets metadata.deletionTimestamp and updates the object.

    * Reconcile 4: Controller is triggered by the update. It finds the object. GetDeletionTimestamp() is now non-nil. The logic enters the finalization block.

    * finalizeDatabase is called. It uses Status.ExternalID to call the external DeleteDatabase API.

    * Scenario A (Success): DeleteDatabase returns nil. The controller proceeds to remove the finalizer and updates the object. The API server now sees an object with a deletion timestamp and an empty finalizer list, and garbage collects it.

    * Scenario B (Failure): DeleteDatabase returns an error. The finalizeDatabase function returns this error. The Reconcile function returns the error to controller-runtime, which schedules a retry after a backoff period. The CR remains in the Terminating state.


    Conclusion

    The finalizer pattern is not merely a feature; it is the fundamental mechanism for building reliable Kubernetes operators that manage external state. By intercepting the deletion process, it transforms a potentially leaky, fire-and-forget operation into a robust, stateful, and retryable cleanup workflow. A correct implementation requires careful separation of concerns in the reconciliation loop, idempotent external interactions, and a keen awareness of failure modes and their impact on the Kubernetes resource lifecycle. While controller-runtime simplifies the boilerplate, the core responsibility of writing resilient finalization logic remains with the senior engineer architecting the operator.

    Found this article helpful?

    Share it with others who might benefit from it.

    More Articles