Week 15: Advanced Kubernetes — StatefulSets, Jobs & Operators

Weeks 11–12 covered the Kubernetes almost every tutorial stops at: Deployments running stateless Pods behind a Service. Real clusters run more than that. This week adds the three workload shapes that come up next — StatefulSets for anything that needs a stable identity and its own storage, Jobs and CronJobs for work that runs once or on a schedule instead of forever, and the Operator pattern, which is how a huge share of production clusters actually manage complex stateful systems like databases and message queues. This is also the start of a more advanced stretch of the course — the next several modules assume the Kubernetes fundamentals from Weeks 11–14 and build features production teams layer on top.

Module 12 of 22 Week 15 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Deploy a StatefulSet with stable network identity and per-Pod persistent storage
  • Run one-off and scheduled work with Jobs and CronJobs
  • Explain what an Operator is, and why it exists on top of a CRD

1. StatefulSets

A Deployment's Pods are interchangeable — any replica can be killed and replaced by an identical one with a random new name and, if you're not using a PersistentVolume, no memory of what came before. That's exactly the right model for a stateless API. It's the wrong model for something like a Postgres primary or a Kafka broker, where "which specific instance is this" and "which specific disk does it own" both matter. A StatefulSet exists for that case: each Pod gets a stable, predictable name (web-0, web-1, web-2, not a random suffix), Pods are created and scaled in strict order, and each Pod's storage follows it through restarts and rescheduling instead of being reassigned.

statefulset.yaml — a 3-node Postgres-style StatefulSet
apiVersion: v1
kind: Service
metadata:
  name: pg
spec:
  clusterIP: None        # headless — required for StatefulSet DNS
  selector: { app: pg }
  ports:
    - port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: pg
spec:
  serviceName: pg          # must match the headless Service above
  replicas: 3
  selector:
    matchLabels: { app: pg }
  template:
    metadata:
      labels: { app: pg }
    spec:
      containers:
        - name: postgres
          image: postgres:16
          ports:
            - containerPort: 5432
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:      # one PVC PER POD, not shared
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests: { storage: 10Gi }

Two things here don't exist on a Deployment. First, the headless Service (clusterIP: None) doesn't load-balance at all — instead it gives each Pod its own stable DNS name, pg-0.pg, pg-1.pg, pg-2.pg, so other services can address a specific replica (the primary, say) instead of "whichever Pod the Service happens to route to." Second, volumeClaimTemplates provisions a distinct PersistentVolumeClaim per Pod — data-pg-0, data-pg-1, data-pg-2 — and if pg-1 is deleted and rescheduled, Kubernetes reattaches the same data-pg-1 volume to the new Pod rather than provisioning a fresh empty one.

A StatefulSet doesn't make your app clustered — it just gives clustering a stable foundation

Deploying three Postgres Pods in a StatefulSet does not give you Postgres replication; each Pod is still an independent, empty database unless you configure replication yourself (or, more realistically, use an Operator — Section 3 — that does it for you). What the StatefulSet actually buys you is the stable identity and storage that any real clustering setup depends on to know who's who and where its data lives.

2. Jobs & CronJobs

A Deployment assumes its Pod should run forever and restarts it if it exits — exactly wrong for a database migration, a report generator, or a one-off data backfill, all of which are supposed to finish. A Job runs a Pod (or several) to completion and stops; a CronJob creates a new Job on a schedule.

job.yaml — a one-off database migration
apiVersion: batch/v1
kind: Job
metadata:
  name: db-migrate
spec:
  backoffLimit: 3          # retry up to 3 times on failure
  activeDeadlineSeconds: 300
  template:
    spec:
      restartPolicy: Never   # Jobs cannot use "Always"
      containers:
        - name: migrate
          image: web-app:latest
          command: ["./manage.py", "migrate"]
cronjob.yaml — a nightly backup
apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-backup
spec:
  schedule: "0 2 * * *"          # 02:00 UTC every day
  concurrencyPolicy: Forbid       # skip a run if the previous one is still going
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: backup
              image: web-app-backup-tool:latest
              command: ["./backup.sh"]

concurrencyPolicy: Forbid matters more than it looks: without it, a backup job that runs long past its next scheduled trigger will simply stack a second one on top, and now two processes are racing against the same backup target. Also worth internalizing — a Job's restartPolicy can only be Never or OnFailure, never Always; a Pod that's supposed to finish can't also be a Pod Kubernetes keeps resurrecting forever.

Set activeDeadlineSeconds on anything you'd rather fail than hang

Without a deadline, a stuck migration or a backup script waiting on a dead connection can run — and consume its Pod's resource allocation — indefinitely. A deadline forces the Job to fail loudly and predictably, which is almost always better than a silent hang someone discovers a day later.

3. The Operator Pattern

A human running Postgres by hand doesn't just start the process — they also handle failover when the primary dies, take backups on a schedule, and safely apply minor version upgrades. Codifying that operational knowledge into software that runs inside the cluster and watches over a specific kind of workload is the Operator pattern. An Operator is built from two pieces: a Custom Resource Definition (CRD), which extends the Kubernetes API with a new object type, and a controller, which watches objects of that type and continuously reconciles real cluster state toward what they declare.

postgres-cluster.yaml — using a Postgres Operator's CRD
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: orders-db
spec:
  instances: 3
  storage:
    size: 20Gi
  backup:
    barmanObjectStore:
      destinationPath: s3://acme-backups/orders-db
      s3Credentials:
        accessKeyId: { name: backup-creds, key: ACCESS_KEY_ID }
        secretAccessKey: { name: backup-creds, key: SECRET_ACCESS_KEY }
    retentionPolicy: "30d"

That's the entire spec — no StatefulSet, no manually wired-up replication, no backup CronJob. The CloudNativePG (or Zalando, or Crunchy) operator installed in the cluster sees this Cluster object, and its controller does the rest: provisions the underlying StatefulSet and PersistentVolumeClaims, configures streaming replication between the three instances, promotes a replica automatically if the primary fails, and runs the scheduled backups to S3 — continuously reconciling reality against this ~15-line declaration, the same reconciliation loop idea behind every built-in Kubernetes controller, just aimed at a domain (Postgres operations) Kubernetes itself knows nothing about.

Prefer a mature, widely-used Operator over hand-rolling the same logic

Writing your own StatefulSet-plus-replication-plus-failover setup for Postgres is redoing work that CloudNativePG, Zalando's Postgres Operator, or Crunchy Data have already solved, tested against real failure scenarios, and maintained for years. Reach for a well-established Operator from the CNCF landscape before building the equivalent logic yourself — the value of the Operator pattern is exactly this: someone else has already encoded the operational expertise.

4. Hands-on Exercise

Hands-on

Run a stateful workload, a scheduled Job, and a real Operator

Apply all three workload shapes from this week on your local kind/minikube cluster.

Requirements:

  1. Deploy a 3-replica StatefulSet running a simple database image with a headless Service, confirm each Pod resolves at its own stable DNS name (pod-0.svc, etc.), then delete one Pod and confirm the replacement reattaches the same PVC rather than starting empty.
  2. Write a Job that runs a short script once, confirm it completes and doesn't restart, then write a CronJob that runs the same script every 2 minutes with concurrencyPolicy: Forbid and watch it fire twice.
  3. Install a real Operator via Helm (CloudNativePG is a good, free choice) and provision a small Postgres cluster through its CRD; confirm the Operator created the underlying StatefulSet and Pods on your behalf.
  4. Kill the Operator-managed primary Pod and confirm the Operator promotes a replica automatically, without you running any manual failover command.
Hint

kubectl get pvc before and after deleting a StatefulSet Pod is the fastest way to see the reattachment behavior directly — the PVC name and its bound volume stay identical across the Pod's replacement.

5. Knowledge Check

Three quick questions. Expand each to check your answer.

Q1

Why does a StatefulSet need a headless Service instead of a normal one?

A normal Service load-balances across all matching Pods behind one virtual IP, which is exactly what a StatefulSet's callers usually don't want — they need to address a specific replica by name. A headless Service (clusterIP: None) skips load balancing and instead gives DNS records to each individual Pod, which is what makes stable per-Pod addressing like pg-0.pg possible.

Q2

Why can't a Job's restartPolicy be Always?

A Job's entire purpose is to run to completion — success is defined as the container eventually exiting 0. restartPolicy: Always means Kubernetes treats any exit, including a successful one, as something to restart from, which would make a Job that's supposed to finish run forever instead. Jobs are restricted to Never or OnFailure so a successful exit is allowed to actually mean "done."

Q3

What does an Operator's controller actually do that a plain StatefulSet manifest can't?

A StatefulSet manifest only describes Pod identity and storage — it has no idea what Postgres replication is or how to fail over. An Operator's controller continuously watches a domain-specific object (like a Cluster CRD) and actively runs the operational logic — configuring replication, promoting a replica on failure, running scheduled backups — that a static manifest has no way to express on its own.