Production-Ready Kubernetes Operators: The Finalizer Pattern in Go
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
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:
deletionTimestamp.deletionTimestamp.We'll define our finalizer's name as a constant for clarity and to avoid magic strings.
internal/controller/manageddatabase_controller.go (Initial Structure)
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)
// ... (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:
client.IgnoreNotFound because a request for a deleted object is expected.dbInstance.GetDeletionTimestamp() != nil is our signal to switch from reconciliation to finalization.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.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.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:
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.Phase: "Deleting" and add a Condition with the reason for the failure. This gives operators visibility into why the object is stuck.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:
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.
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.