Kubernetes Finalizers for Graceful External Resource Deletion
The Deletion Dichotomy: Kubernetes State vs. External Reality
In a purely Kubernetes-native world, resource management is elegant. You kubectl delete deployment my-app, and the control plane orchestrates a graceful termination of pods, ReplicaSets, and associated resources. This works because the entire lifecycle is managed within the Kubernetes API. However, the moment a custom controller or operator begins managing resources outside the cluster—a cloud database, a DNS record, an S3 bucket—this elegance breaks down.
When a user deletes a Custom Resource (CR) that represents an external asset, the Kubernetes garbage collector simply removes the object from etcd. The controller, which was watching this object, receives a delete event. But by the time it acts, the object is gone. The controller's reconciliation loop, which contains the logic to provision and configure the external resource, loses its trigger. The result is a classic and costly problem: orphaned external resources. The CR is gone, but the expensive RDS instance it managed is still running, silently accruing costs.
This is the core problem that finalizers solve. They are not a library or a complex feature, but a simple, powerful primitive within the Kubernetes API. A finalizer is a key in a list on an object's metadata that tells the API server: "Do not fully delete this object yet. An external controller has cleanup work to do." It effectively inserts a blocking, asynchronous pre-delete hook into the Kubernetes garbage collection process, giving your controller the time and state required to perform graceful, out-of-cluster cleanup.
This article is not an introduction. We assume you understand the operator pattern, Custom Resource Definitions (CRDs), and the basics of controller-runtime. We will dive directly into the production implementation of a finalizer-aware controller, focusing on idempotency, error handling, and the edge cases that separate a prototype operator from a production-hardened one.
Our running example will be a Database controller that manages a database instance in a hypothetical external cloud service.
Anatomy of a Deletion without Finalizers: A Recipe for Leaks
To fully appreciate the finalizer pattern, let's first examine a naive controller implementation and witness its failure mode.
Imagine our Database CRD:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databases.db.mycompany.com
spec:
group: db.mycompany.com
names:
kind: Database
plural: databases
singular: database
shortNames:
- db
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
engine:
type: string
enum: ["postgres", "mysql"]
version:
type: string
status:
type: object
properties:
dbInstanceId:
type: string
phase:
type: string
A naive reconciler's logic might look like this (pseudo-code):
func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
var database dbv1alpha1.Database
if err := r.Get(ctx, req.NamespacedName, &database); err != nil {
// This is where the problem lies.
// If the error is `apierrors.IsNotFound(err)`, the object is already gone.
// It's too late to call our cleanup logic.
if apierrors.IsNotFound(err) {
log.Info("Database resource not found. Ignoring since object must be deleted.")
// We CANNOT perform cleanup here because we no longer have the object's spec or status.
// What was the external DB instance ID? We don't know.
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
// ... Normal reconciliation logic ...
// 1. Check if external DB exists using database.Status.DBInstanceID
// 2. If not, create it via cloud API.
// 3. Update database.Status with the new ID and Phase.
// 4. If it exists, ensure its state (e.g., version) matches database.Spec.
return ctrl.Result{}, nil
}
When a user runs kubectl delete database my-prod-db, the following happens:
- The Kubernetes API server receives the delete request.
Database object my-prod-db is removed from etcd.DELETED event.Reconcile function is triggered for my-prod-db.r.Get(...) call fails with a NotFound error.- The reconciler, as written, logs a message and returns, assuming its job is done.
The external database instance, whose ID was stored in the now-deleted database.Status.DBInstanceID, is now an orphan. This is a critical failure in any system managing real-world, stateful resources.
The Finalizer-Aware Reconciliation Loop
The correct approach involves altering the reconciliation loop to be aware of the object's deletion state. Kubernetes signals this by setting a deletionTimestamp on the object's metadata. The object is not yet removed from etcd, but it is marked for deletion.
The finalizer-aware control loop has two primary paths:
deletionTimestamp is zero, the object is alive. The controller performs its normal duties: create, update, and ensure the external resource matches the spec. It also ensures its finalizer is present on the CR.deletionTimestamp is non-zero, the object is being deleted. The controller must now ignore the spec and focus exclusively on cleanup. It performs the external resource deletion and, only upon success, removes its finalizer from the CR. Once the metadata.finalizers list is empty, the Kubernetes garbage collector is free to permanently delete the object.Production Implementation with `controller-runtime`
Let's build a robust reconciler that correctly implements this pattern. We'll start with the reconciler struct and a mock external client interface to keep the example self-contained.
File: internal/controller/database_controller.go
package controller
import (
"context"
"fmt"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"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/my-org/db-operator/api/v1alpha1"
)
// A mock external client for demonstration purposes.
// In a real implementation, this would interact with a cloud provider's API.
type ExternalDBClient interface {
CreateDatabase(ctx context.Context, db *dbv1alpha1.Database) (string, error)
GetDatabaseStatus(ctx context.Context, instanceID string) (string, error)
DeleteDatabase(ctx context.Context, instanceID string) error
}
// DatabaseReconciler reconciles a Database object
type DatabaseReconciler struct {
client.Client
Scheme *runtime.Scheme
ExternalClient ExternalDBClient
}
// finalizerName is the name of the finalizer our controller adds to Database objects.
// It's a best practice to use a domain-qualified name to avoid collisions.
const finalizerName = "db.mycompany.com/finalizer"
//+kubebuilder:rbac:groups=db.mycompany.com,resources=databases,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=db.mycompany.com,resources=databases/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=db.mycompany.com,resources=databases/finalizers,verbs=update
func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// 1. Fetch the Database instance
var database dbv1alpha1.Database
if err := r.Get(ctx, req.NamespacedName, &database); err != nil {
if apierrors.IsNotFound(err) {
logger.Info("Database resource not found. Ignoring.")
return ctrl.Result{}, nil
}
logger.Error(err, "Failed to get Database resource")
return ctrl.Result{}, err
}
// 2. Examine the deletion timestamp to determine if the object is under deletion.
if !database.ObjectMeta.DeletionTimestamp.IsZero() {
// The object is being deleted
return r.reconcileDelete(ctx, &database)
} else {
// The object is not being deleted, so we proceed with normal reconciliation.
return r.reconcileNormal(ctx, &database)
}
}
This main Reconcile function is now just a dispatcher. It fetches the object and routes it to either the deletion or normal reconciliation logic based on the DeletionTimestamp.
The Normal Reconciliation Path: Adding the Finalizer
In reconcileNormal, our first job is to ensure our finalizer is present. If it's not, we add it and return. This is a critical step. The controllerutil.AddFinalizer function is idempotent; it won't add duplicates.
func (r *DatabaseReconciler) reconcileNormal(ctx context.Context, db *dbv1alpha1.Database) (ctrl.Result, error) {
logger := log.FromContext(ctx).WithValues("database", db.Name)
// Add finalizer if it doesn't exist. This is essential for the deletion logic.
if !controllerutil.ContainsFinalizer(db, finalizerName) {
logger.Info("Adding finalizer for Database")
controllerutil.AddFinalizer(db, finalizerName)
if err := r.Update(ctx, db); err != nil {
logger.Error(err, "Failed to add finalizer")
return ctrl.Result{}, err
}
// Requeue immediately after adding the finalizer to process the rest of the logic.
return ctrl.Result{Requeue: true}, nil
}
// --- Main Reconciliation Logic ---
// If the instance ID is not set, we need to create the database.
if db.Status.DBInstanceID == "" {
logger.Info("DBInstanceID not found, creating external database")
instanceID, err := r.ExternalClient.CreateDatabase(ctx, db)
if err != nil {
logger.Error(err, "Failed to create external database")
// Update status with a failure condition
db.Status.Phase = "Failed"
// Omitting status update logic for brevity, but it's crucial here.
return ctrl.Result{}, err
}
db.Status.DBInstanceID = instanceID
db.Status.Phase = "Creating"
if err := r.Status().Update(ctx, db); err != nil {
logger.Error(err, "Failed to update Database status after creation")
return ctrl.Result{}, err
}
// Requeue to check status later.
return ctrl.Result{RequeueAfter: 15 * time.Second}, nil
}
// Check the status of the external database.
status, err := r.ExternalClient.GetDatabaseStatus(ctx, db.Status.DBInstanceID)
if err != nil {
logger.Error(err, "Failed to get external database status")
return ctrl.Result{}, err
}
if db.Status.Phase != status {
db.Status.Phase = status
if err := r.Status().Update(ctx, db); err != nil {
logger.Error(err, "Failed to update Database status")
return ctrl.Result{}, err
}
}
logger.Info("Reconciliation successful")
return ctrl.Result{}, nil
}
The Deletion Path: Cleanup and Finalizer Removal
This is the heart of the pattern. reconcileDelete is called only when the object is marked for deletion. Its sole responsibility is to orchestrate the cleanup.
func (r *DatabaseReconciler) reconcileDelete(ctx context.Context, db *dbv1alpha1.Database) (ctrl.Result, error) {
logger := log.FromContext(ctx).WithValues("database", db.Name)
// Check if our finalizer is still present.
if controllerutil.ContainsFinalizer(db, finalizerName) {
logger.Info("Performing cleanup for Database")
// If DBInstanceID is present, we need to delete the external resource.
if db.Status.DBInstanceID != "" {
if err := r.ExternalClient.DeleteDatabase(ctx, db.Status.DBInstanceID); err != nil {
// IMPORTANT: If the cleanup fails, we must return an error.
// This will cause the Reconcile function to be called again, retrying the cleanup.
logger.Error(err, "Failed to delete external database")
// We don't remove the finalizer, so the deletion is retried.
return ctrl.Result{}, err
}
}
// Once external resource is gone (or was never created), remove the finalizer.
logger.Info("External resource deleted, removing finalizer")
controllerutil.RemoveFinalizer(db, finalizerName)
if err := r.Update(ctx, db); err != nil {
logger.Error(err, "Failed to remove finalizer")
return ctrl.Result{}, err
}
}
// The finalizer has been removed. The object will now be deleted by Kubernetes.
logger.Info("Database object finalized and will be deleted")
return ctrl.Result{}, nil
}
Crucially, if r.ExternalClient.DeleteDatabase returns an error, we do not remove the finalizer. We return the error, which signals to controller-runtime to requeue the request. The deletion logic will be attempted again after a backoff period, providing a robust, automatic retry mechanism.
Advanced Scenarios and Production Hardening
The implementation above is correct, but production environments introduce complexity. Let's analyze and solve for common edge cases.
Edge Case 1: Idempotency of the Deletion Logic
Problem: Our reconcileDelete function might be called multiple times due to retries. What happens if the first call to DeleteDatabase succeeds, but the subsequent r.Update call to remove the finalizer fails (e.g., API server is temporarily unavailable)? The controller will retry the entire reconcileDelete function. It will call DeleteDatabase on an already-deleted resource.
Solution: The external client's DeleteDatabase method must be idempotent. A call to delete a non-existent resource should be treated as a success, not an error.
Let's refine our mock client to demonstrate this.
// Mock implementation of ExternalDBClient
type MockExternalDBClient struct {
Databases map[string]string // instanceID -> status
}
func (c *MockExternalDBClient) DeleteDatabase(ctx context.Context, instanceID string) error {
log.FromContext(ctx).Info("Mock client: received request to delete", "instanceID", instanceID)
if _, ok := c.Databases[instanceID]; !ok {
// This is the key to idempotency. If it's already gone, we succeed.
log.FromContext(ctx).Info("Mock client: database not found, considering it a success", "instanceID", instanceID)
return nil
}
delete(c.Databases, instanceID)
log.FromContext(ctx).Info("Mock client: database deleted successfully", "instanceID", instanceID)
return nil
}
// Other mock methods (CreateDatabase, GetDatabaseStatus) omitted for brevity.
By checking if the resource exists before attempting deletion and returning nil if it's already gone, we make our cleanup logic safe to retry indefinitely.
Edge Case 2: Controller Crash During Deletion
Problem: What if the controller process crashes at the worst possible moment?
DeleteDatabase call to the external API succeeds.controllerutil.RemoveFinalizer.Solution: The finalizer pattern inherently handles this. When the controller pod restarts, its reconciliation loop for the my-prod-db object will be triggered again. It will see that the deletionTimestamp is set and the finalizer is still present. It will enter the reconcileDelete path and call DeleteDatabase again. Because our deletion logic is now idempotent (from Edge Case 1), this second call will succeed (as a no-op). The controller will then proceed to remove the finalizer, and the process completes correctly. This demonstrates the self-healing nature of the pattern.
Edge Case 3: The Stuck Finalizer
Problem: A bug in the controller, a misconfiguration, or a permanently unavailable external API could prevent the cleanup logic from ever succeeding. The finalizer is never removed, and the object gets stuck in the Terminating state forever. kubectl delete commands will appear to hang.
Solution: This is an operational issue that requires manual intervention. An administrator must:
kubectl logs -n my-operator-system deployment/my-operator-controller-manager -c manager | grep "my-prod-db"
kubectl patch database my-prod-db -n my-namespace --type='json' -p='[{"op": "remove", "path": "/metadata/finalizers"}]'
This is a last-resort command. Providing clear logging and status conditions in your controller is crucial for making this diagnosis process as painless as possible.
Performance and API Server Load
Every time a finalizer is added or removed, it results in an UPDATE API call to the Kubernetes API server. For a single object, this is negligible. However, in a large, dynamic cluster where thousands of CRs might be created and deleted, this can add up.
* Latency: The initial reconciliation of a new object will always involve at least two writes: one to add the finalizer and another to update the status. This adds a small amount of latency to the provisioning process.
* Throttling: High churn of objects managed by your controller could contribute to API server request throttling. While controller-runtime's client-side rate limiting helps, it's a factor to be aware of in massive-scale systems.
In most use cases, these performance considerations are minor compared to the correctness and safety guarantees that finalizers provide. The cost of an extra API call is far lower than the cost of an orphaned, running database.
Conclusion: Beyond a Feature, A Fundamental Pattern
Mastering the finalizer pattern is a non-negotiable skill for any engineer building production-grade Kubernetes operators. It is the canonical, Kubernetes-native solution for managing the lifecycle of external resources, ensuring that the declarative state within the cluster can be safely reconciled with the imperative reality of the outside world.
A well-implemented finalizer-aware controller prevents resource leaks, provides robust and automatic retries for cleanup operations, and can gracefully recover from controller failures. By building idempotent external clients and planning for operational edge cases like stuck finalizers, you can create controllers that are not just functional but truly resilient. The finalizer isn't just another feature in the Kubernetes API; it's a fundamental building block for extending the power of the control plane beyond the cluster's edge.