StatefulSets and the Operator Pattern: When CRDs Beat Helm Charts

Sources

For most engineers, the StatefulSet (STS) is the go-to for anything that needs a disk. You get a stable network ID (web-0, web-1), a persistent volume that follows the pod, and ordered deployment. On paper, it is the perfect abstraction for a database.

In production, the StatefulSet is a leaky abstraction. It provides the mechanics of state (storage and identity) but none of the semantics of distributed systems (quorum, leadership, and data consistency). Relying on a raw StatefulSet for a complex stateful app is essentially betting that your application's internal logic can survive the blunt-force trauma of the Kubernetes controller.

The Anatomy of a StatefulSet Failure

The StatefulSet controller is generic by design. It treats every pod as an identical unit with an ordinal. This creates three critical failure modes in high-availability (HA) environments.

1. The \"Primary-First\" Update Disaster

StatefulSets perform rolling updates in reverse ordinal order (N-1 down to 0). In a 2-node Postgres cluster, pod-1 is updated first. If pod-1 is the current Primary, the STS kills it, triggering a failover to pod-0. Once pod-1 is back and healthy, the STS moves to pod-0. Since pod-0 is now the Primary, the STS kills it, triggering a second failover back to pod-1.

The result: two unnecessary failovers for a single version bump, because the STS is blind to the application's leadership state.

2. The \"Ordinal-Blind\" Downscale

Scaling down a StatefulSet always terminates the highest ordinal first. If you scale from 3 replicas to 2, pod-2 is deleted. If pod-2 happened to be the Primary, you have just deleted your leader. An Operator, by contrast, would identify the Primary, trigger a controlled switchover to pod-0, and only then delete pod-2.

3. The \"Ready-but-Lagging\" Lie

Kubernetes defines \"Ready\" via a probe. For a database, a probe usually just checks if the process is listening on port 5432. It does not check if the replica has caught up with the Primary's WAL stream. The STS controller sees a Ready pod and immediately kills the next one in the sequence, potentially destroying the only other up-to-date replica and leaving the cluster in a fragile, under-replicated state.

The Blueprint: The Operator Architecture

An Operator transforms a generic workload into a domain-aware system. It replaces the blunt logic of the StatefulSet with a Reconciliation Loop that integrates with the application's own API.

+-------------------+       +-------------------------+       +-------------------------+
|   User Intent      |       |    Operator Controller   |       |     Managed Stack       |
|   (Custom Res)     |       |   (Control Loop / Logic)  |       |    (Pods, PVCs, SVCs)    |
+---------+---------+       +------------+------------+       +------------+------------+
          |                              |                              |
          | 1. Defines State            |                              |
          |    (replicas: 3,             |                              |
          |     version: v1.4)           |                              |
          v                              |                              |
+-------------------+                   |                              |
|  Custom Resource  |-------------------+                              |
|   (e.g. Postgres)  |  2. Watch / Diff / Act                           |
+-------------------+                   |                              |
                                        | 3. Query App API              |
                                        |    (Who is Primary?            |
                                        |     Is Replica caught up?)    |
                                        |                              v
                                        |                    +-------------------------+
                                        |                    |    App API / Admin Port   |
                                        |                    | (Status: Primary=pod-1)  |
                                        |                    +------------+------------+
                                        |                              |
                                        | 4. Orchestrate                |
                                        |    - Update pod-0            |
                                        |    - Switchover to pod-0     |
                                        |    - Update pod-1            |
                                        +------------------------------>|

The Reconciliation Loop Logic

Unlike the STS, which is purely reactive to the Spec, the Operator implements a state machine. A typical \"Update\" reconciliation looks like this:

Production-Grade Manifests

To implement this, you need a Custom Resource Definition (CRD) that captures the intent, and a controller that acts on it. Below is a conceptual blueprint for a Postgres Operator.

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: postgres.database.platformmonkey.com
spec:
  group: database.platformmonkey.com
  versions:
    - name: v1alpha1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                replicas: {type: integer, minimum: 1}
                version: {type: string}
                storageSize: {type: string}
                backupSchedule: {type: string}
            status:
              type: object
              properties:
                currentPrimary: {type: string}
                clusterHealth: {type: string}
                lastBackup: {type: string}
func (r *PostgresReconciler) Reconcile(req ctrl.Request) {
    // 1. Get the Custom Resource
    pg := &databasev1.Postgres{}
    r.Get(ctx, req.NamespacedName, pg)

    // 2. Get actual cluster state from the App API
    actualState := r.AppClient.GetClusterStatus() 
    // actualState.Primary = "pod-1"

    // 3. Check for version mismatch
    if actualState.Version != pg.Spec.Version {
        // Determine update order based on leadership
        var updateOrder []string
        for _, pod := range actualState.Pods {
            if pod.IsPrimary {
                updateOrder = append(updateOrder, pod.Name) // Primary last
            } else {
                updateOrder = append(updateOrder, pod.Name) // Replicas first
            }
        }
        sort.Slice(updateOrder, func(i, j int) bool {
            return !actualState.Pods[updateOrder[i]].IsPrimary
        })

        // 4. Execute sequentially
        for _, podName := range updateOrder {
            r.updatePod(podName, pg.Spec.Version)
            r.waitForReplicationCatchup(podName) // App-aware wait
        }
    }
}

Production Realities

The \"Operator Complexity Tax\"

Operators are not free. You are replacing a simple K8s controller with a custom piece of software that has its own bugs. A poorly written Operator can enter a reconciliation storm, where it constantly fights with another controller or its own failed state, creating a loop of Pod restarts that is harder to debug than a simple STS failure.

The Split-Brain Controller

If your Operator has a bug in its leadership detection, it might trigger a failover while the original Primary is still healthy, creating two Primaries (split-brain). In stateful systems, split-brain is the ultimate failure; it leads to data corruption that cannot be fixed by a restart. This is why production Operators (like CloudNativePG) rely on an external consensus store (like etcd) or a strict fencing mechanism (STONITH) to ensure only one Primary exists.

The Immutable Volume Trap

StatefulSets have immutable volumeClaimTemplates. If you need to resize a disk, the STS controller cannot help you. An Operator solves this by interacting with the PersistentVolumeClaim (PVC) and the PersistentVolume (PV) directly, updating the size in the API and then triggering a rolling restart of the pods to let the file system expand—a process that is transparent to the user but manually intensive with raw STS.

Comparison: STS vs. Operator

Dimension              | Raw StatefulSet          | Domain-Aware Operator
-----------------------|--------------------------|-------------------------------------------
Update Order           | Fixed (N-1 to 0)          | Dynamic (Replica -> Primary)
Scaling Down           | Fixed (Highest ordinal)  | Smart (Non-primary first)
Readiness Signal       | Liveness/Readiness Probe  | App-API (Replication Lag / Quorum)
Volume Resizing        | Manual / Impossible      | Automated PVC manipulation
Day 2 Ops (Backups)    | External CronJob          | Integrated into the CRD spec
Failover Trigger       | Kubelet (Pod Death)       | App-aware (Heartbeat / Quorum Loss)
Complexity             | Low (Built-in)            | High (Custom Code + CRDs)

Who Should Skip This

References & Further Reading