Updating StatefulSets — Rolling Updates, Canaries, and Rollbacks

Lesson 39 of 39 18 min read Updated

In the previous article you learned that a StatefulSet scales in a strict order. That same ordering rules how a new version of your application is released. Changing the Pod template (.spec.template) triggers a rollout, exactly as it does for a Deployment, but the way that rollout proceeds is quite different.

This article covers the two update strategies, the partition field that turns a rollout into a canary release, what a broken rollout looks like and how to escape it, and how to roll back safely when the thing you are upgrading holds data.

We will cover the following topics:

  1. The RollingUpdate and OnDelete strategies
  2. Why rollouts run in reverse ordinal order
  3. Staged and canary releases with .spec.updateStrategy.rollingUpdate.partition
  4. How a failed rollout stalls, and the only way to recover it
  5. Rolling back, and why a database makes rollback dangerous

Technical Requirements

  1. A running Kubernetes cluster with a default StorageClass. Docker Desktop, minikube, or a cloud cluster all work.
  2. kubectl installed and configured.
  3. Some patience. Because each Pod is replaced one at a time and each waits to become ready, a rollout of three replicas takes a couple of minutes.

Core Concepts

The Two Update Strategies

You choose a strategy with the .spec.updateStrategy.type field. There are exactly two options.

RollingUpdate is the default. It terminates and recreates Pods one at a time, in order, and waits for each replacement to be Running and Ready before moving on. It resembles the Deployment strategy of the same name, but it is not identical: a Deployment can bring up several new Pods at once and can run old and new Pods side by side under maxSurge. A StatefulSet does neither. There is exactly one Pod in flight at any moment.

OnDelete is the legacy behaviour from before Kubernetes 1.7, and it is still genuinely useful. Under this strategy the controller updates the template but does not recreate any Pod. Nothing happens until you delete a Pod by hand, and only then does the replacement pick up the new template.

OnDelete exists for workloads where a human or an external process must check something between replicas. If you run a Cassandra or etcd cluster, you may want to confirm that the replacement node rejoined the cluster correctly before touching the next one. You can express similar checks with postStart and preStop lifecycle hooks under RollingUpdate, but the hooks need careful error handling and are harder to get right.


RollingUpdateOnDelete
Who replaces PodsThe controller, automaticallyYou, by deleting them
OrderReverse ordinal, one at a timeWhatever order you delete in
kubectl rollout statusWorksRefuses to run
Good forMost workloadsClusters needing a manual check between replicas

Rollouts Run in Reverse Order

A RollingUpdate respects every guarantee you learned about scaling. Pods are replaced from the highest ordinal downwards: chat-history-2 first, then chat-history-1, and chat-history-0 last.

This order is deliberate. Ordinal 0 is conventionally the most important replica, often the primary in a replicated database. Updating it last means the riskiest change happens after the lower-stakes replicas have already proved the new version works.

What Happens When a Rollout Fails

If a rollout goes wrong, the controller tries to keep the application consistent. Pods already updated stay on the new version; Pods not yet reached stay on the old one. The StatefulSet stops and waits.

Here is the part that surprises people. If a replacement Pod never becomes Running and Ready, the rollout halts and waits for manual intervention. Re-applying the previous, working manifest is not enough. The controller will not proceed, because it is still waiting for that failed Pod to become ready, and a Pod that cannot start will never satisfy it.

The only way out is to delete the failed Pod yourself. Once it is gone, the controller recreates it from whatever template is current, and the rollout unblocks. We will reproduce this exact situation later in the article, because reading about it is far less convincing than watching it happen.

Staged Rollouts with partition

The .spec.updateStrategy.rollingUpdate.partition field is what makes canary releases possible. The rule is simple:

Every Pod whose ordinal is lower than partition is left alone. Those Pods keep the old template, and even if you delete one, it comes back on the old version.

The default is 0, which is why an ordinary rollout touches every Pod: every ordinal is greater than or equal to zero. With three replicas, other values behave like this:

partitionPods updatedMeaning
3NoneStaged. The template is stored but nothing moves.
2-2 onlyA single canary replica.
1-2 and -1A phased rollout, ordinal 0 still protected.
0All threeFull rollout. The default.

The workflow is to set partition equal to replicas, apply your new template so it is staged but inert, then walk the number down, verifying at each step.

Rolling Back a Stateful Application

Technically, rollback works the same as for a Deployment: kubectl rollout undo reverts to the previous revision. For StatefulSets, however, the declarative approach is strongly preferred. Keep your manifests in a source repository, revert the commit, and apply again. This fits the Infrastructure as Code model and usually runs through a CI/CD pipeline rather than an operator typing commands.

There is also a practical reason. Running kubectl rollout undo on an object you manage with kubectl apply leaves the stored last-applied-configuration annotation out of step with the live object, and your next apply can behave unexpectedly. Kubernetes warns you about this when you do it.

The deeper danger is data. Downgrading a container image does not downgrade your data. If the new version migrated a schema, changed an on-disk format, or wrote records the old version cannot read, rolling the image back leaves the old binary staring at data it does not understand. The application will refuse to start, or worse, start and misbehave.

Before you roll back anything stateful, ask whether a downward migration exists. If it does not, the safe path is to roll forward with a fix, not backward to the previous image.

Hands-On: Kubernetes Commands

Watch a rollout in progress

Prints a line per phase. It takes noticeably longer than a Deployment because Pods go one at a time.

kubectl rollout status statefulset chat-history -n chatops

List the stored revisions

Each revision is backed by a ControllerRevision object holding a snapshot of the Pod template.

kubectl rollout history statefulset chat-history -n chatops
kubectl get controllerrevision -n chatops -l app=chat-history

Check which image a Pod is actually running

Reading the Pod, not the StatefulSet, is what tells you whether the update reached that replica.

kubectl get pod chat-history-0 -n chatops -o jsonpath='{.spec.containers[0].image}'

See the recreation order in the events

The controller logs each delete and recreate, so you can confirm the reverse ordering afterwards.

kubectl describe sts chat-history -n chatops

Change the partition imperatively

Useful for a quick experiment, though editing the manifest is clearer and easier to review.

kubectl patch sts chat-history -n chatops \
-p '{"spec":{"updateStrategy":{"type":"RollingUpdate","rollingUpdate":{"partition":2}}}}'

Change the image imperatively

Acceptable for testing. For production, change the manifest and apply it.

kubectl set image statefulset chat-history -n chatops postgres=postgres:17.2

Roll back to the previous revision

Reverts the Pod template. It does not, and cannot, revert your data.

kubectl rollout undo statefulset chat-history -n chatops

Step-by-Step Example

The Scenario

We will run a chat history database as a three-replica PostgreSQL StatefulSet named chat-history, starting on postgres:17.0. A chat-shell Pod gives us a client. We will write a message, then carry it through a full rolling update, a staged canary release, a deliberately broken rollout, and a rollback, checking after each one that the message is still there.

We use PostgreSQL patch versions (17.0, 17.2, 17.4) on purpose. They share an on-disk format, so the same data directory works with all of them. Later we will use a major version change to break things intentionally.

Step 1 — Create the Namespace, Secret, and Service

apiVersion: v1
kind: Namespace
metadata:
name: chatops
kubectl apply -f 1-chatops-namespace.yaml

The credentials decode to chatadmin and Ch@tH1st2026.

apiVersion: v1
kind: Secret
metadata:
name: chat-history-secret
namespace: chatops
type: Opaque
data:
POSTGRES_USER: Y2hhdGFkbWlu
POSTGRES_PASSWORD: Q2hAdEgxc3QyMDI2
kubectl apply -f 2-chat-history-secret.yaml

The headless Service gives each replica its stable DNS name, as covered in the previous article.

apiVersion: v1
kind: Service
metadata:
name: chat-history-headless
namespace: chatops
labels:
app: chat-history
spec:
clusterIP: None
selector:
app: chat-history
ports:
- name: postgres
port: 5432
targetPort: 5432
kubectl apply -f 3-chat-history-headless-service.yaml

Step 2 — Create the StatefulSet

Note that updateStrategy and partition are written out explicitly even though these are the default values. The book-standard advice applies here: spelling out the defaults makes the manifest self-documenting, and it gives you an obvious place to edit when you stage a rollout.

apiVersion: apps/v1
kind: StatefulSet
metadata:
name: chat-history
namespace: chatops
labels:
app: chat-history
spec:
serviceName: chat-history-headless
replicas: 3
podManagementPolicy: OrderedReady
updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 0
selector:
matchLabels:
app: chat-history
template:
metadata:
labels:
app: chat-history
spec:
containers:
- name: postgres
image: postgres:17.0
ports:
- name: postgres
containerPort: 5432
env:
- name: POSTGRES_DB
value: chatdb
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: chat-history-secret
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: chat-history-secret
key: POSTGRES_PASSWORD
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: chat-data
mountPath: /var/lib/postgresql/data
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "1Gi"
readinessProbe:
exec:
command:
- sh
- -c
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
livenessProbe:
exec:
command:
- sh
- -c
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 5
failureThreshold: 3
volumeClaimTemplates:
- metadata:
name: chat-data
spec:
accessModes:
- "ReadWriteOnce"
resources:
requests:
storage: 1Gi
kubectl apply -f 4-chat-history-statefulset.yaml

Now the client Pod. It runs a newer PostgreSQL image only because we need the psql binary; it is not part of the StatefulSet.

apiVersion: v1
kind: Pod
metadata:
name: chat-shell
namespace: chatops
labels:
app: chat-shell
spec:
containers:
- name: shell
image: postgres:17.4
command:
- sleep
- infinity
env:
- name: PGUSER
valueFrom:
secretKeyRef:
name: chat-history-secret
key: POSTGRES_USER
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: chat-history-secret
key: POSTGRES_PASSWORD
- name: PGDATABASE
value: chatdb
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"
restartPolicy: Always
kubectl apply -f 5-chat-shell-pod.yaml
kubectl rollout status statefulset chat-history -n chatops

Confirm the starting version:

kubectl exec chat-history-0 -n chatops -- psql -U chatadmin -d chatdb -tAX -c "SHOW server_version;"
17.0 (Debian 17.0-1.pgdg120+1)

Step 3 — Write Data We Can Track

Create a database and a row on chat-history-0. Everything that follows will check that this data survives.

kubectl exec chat-shell -n chatops -- psql -h chat-history-0.chat-history-headless \
-c "CREATE DATABASE stsrolling;"
kubectl exec chat-shell -n chatops -- psql -h chat-history-0.chat-history-headless \
-c "CREATE TABLE messages (id SERIAL PRIMARY KEY, body TEXT); INSERT INTO messages (body) VALUES ('written on 17.0');"
CREATE DATABASE
CREATE TABLE
INSERT 0 1

Remember from the earlier article that this data exists only on chat-history-0. The other two replicas have their own empty volumes.

Step 4 — A Full Rolling Update

Edit the image in the manifest:

spec:
containers:
- name: postgres
image: postgres:17.2
kubectl apply -f 4-chat-history-statefulset.yaml
kubectl rollout status statefulset chat-history -n chatops

The status output shows the rollout advancing one Pod at a time:

Waiting for partitioned roll out to finish: 0 out of 3 new pods have been updated...
Waiting for 1 pods to be ready...
Waiting for partitioned roll out to finish: 1 out of 3 new pods have been updated...
Waiting for 1 pods to be ready...
Waiting for partitioned roll out to finish: 2 out of 3 new pods have been updated...
Waiting for 1 pods to be ready...
partitioned roll out complete: 3 new pods have been updated...

If you watch the Pods in another terminal with kubectl get pods -n chatops -w, the reverse order is unmistakable. Sampled every few seconds, it looks like this:

chat-history-0(1/1,Running) chat-history-1(1/1,Running) chat-history-2(1/1,Terminating)
chat-history-0(1/1,Running) chat-history-1(1/1,Running) chat-history-2(0/1,ContainerCreating)
chat-history-0(1/1,Running) chat-history-1(0/1,ContainerCreating) chat-history-2(1/1,Running)
chat-history-0(0/1,Running) chat-history-1(1/1,Running) chat-history-2(1/1,Running)

Ordinal 2 goes first, ordinal 0 last, and only one Pod is ever unavailable. Verify the result and that the data came through:

for p in 0 1 2; do
printf "chat-history-$p "
kubectl get pod chat-history-$p -n chatops -o jsonpath='{.spec.containers[0].image}'
echo
done
chat-history-0 postgres:17.2
chat-history-1 postgres:17.2
chat-history-2 postgres:17.2
kubectl exec chat-shell -n chatops -- psql -h chat-history-0.chat-history-headless \
-c "SELECT * FROM messages;"
id | body
----+-----------------
1 | written on 17.0
(1 row)

The row written under 17.0 is readable under 17.2. The Pod was destroyed and rebuilt, but it re-attached the same PersistentVolumeClaim.

Step 5 — Stage a Rollout Without Changing Anything

Now a canary release to postgres:17.4. First set partition equal to the replica count so that applying the new template changes nothing:

updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 3

Change the image to postgres:17.4 at the same time, then apply:

kubectl apply -f 4-chat-history-statefulset.yaml
kubectl rollout status statefulset chat-history -n chatops
partitioned roll out complete: 0 new pods have been updated...

Check the Pods. All three still run the old image, and none restarted:

chat-history-0 postgres:17.2
chat-history-1 postgres:17.2
chat-history-2 postgres:17.2

The new template is stored and ready, waiting for you to lower the number. This is what staging a rollout means.

Step 6 — Release One Canary

Lower partition to 2. Only ordinal 2 qualifies for an update:

updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 2
kubectl apply -f 4-chat-history-statefulset.yaml
kubectl rollout status statefulset chat-history -n chatops
Waiting for partitioned roll out to finish: 0 out of 1 new pods have been updated...
Waiting for 1 pods to be ready...
partitioned roll out complete: 1 new pods have been updated...

Notice the wording: 1 new pod, not 3. Now compare the images:

chat-history-0 postgres:17.2
chat-history-1 postgres:17.2
chat-history-2 postgres:17.4

Run a smoke test against the canary and against an untouched replica:

kubectl exec chat-shell -n chatops -- psql -h chat-history-2.chat-history-headless -tAX -c "SHOW server_version;"
kubectl exec chat-shell -n chatops -- psql -h chat-history-0.chat-history-headless -tAX -c "SHOW server_version;"
17.4 (Debian 17.4-1.pgdg120+2)
17.2 (Debian 17.2-1.pgdg120+1)

Two versions running side by side in one StatefulSet, each addressable by its own stable DNS name. This is where you would run your real verification: application tests, query checks, log inspection.

Step 7 — Complete the Phased Rollout

The canary looks healthy, so bring partition back to 0:

updateStrategy:
type: RollingUpdate
rollingUpdate:
partition: 0
kubectl apply -f 4-chat-history-statefulset.yaml
kubectl rollout status statefulset chat-history -n chatops
Waiting for partitioned roll out to finish: 1 out of 3 new pods have been updated...
Waiting for 1 pods to be ready...
Waiting for partitioned roll out to finish: 2 out of 3 new pods have been updated...
Waiting for 1 pods to be ready...
partitioned roll out complete: 3 new pods have been updated...

The status starts at "1 out of 3" because the canary was already updated. Only the two remaining Pods are replaced. Between the canary and this step you could have paused at partition: 1 for a smaller phase.

All three replicas are now on 17.4, and the original message is still there:

chat-history-0 postgres:17.4
chat-history-1 postgres:17.4
chat-history-2 postgres:17.4

id | body
----+-----------------
1 | written on 17.0
(1 row)

Step 8 — Break the Rollout on Purpose

Now let's see what a stuck rollout really looks like. Change the image to postgres:16, a major version downgrade. PostgreSQL 16 cannot read a data directory written by version 17, so the container will fail to start.

spec:
containers:
- name: postgres
image: postgres:16
kubectl apply -f 4-chat-history-statefulset.yaml
kubectl get pods -n chatops -l app=chat-history -w

Sampled every five seconds, the rollout goes nowhere:

t+05s chat-history-0(1/1,Running) chat-history-1(1/1,Running) chat-history-2(1/1,Terminating)
t+10s chat-history-0(1/1,Running) chat-history-1(1/1,Running) chat-history-2(0/1,ContainerCreating)
t+30s chat-history-0(1/1,Running) chat-history-1(1/1,Running) chat-history-2(0/1,CrashLoopBackOff)
t+60s chat-history-0(1/1,Running) chat-history-1(1/1,Running) chat-history-2(0/1,CrashLoopBackOff)

Ordinals 0 and 1 are untouched and still serving on 17.4. Only the first Pod in the rollout is broken, and the controller will not move past it. The logs say exactly why:

kubectl logs chat-history-2 -n chatops --tail=5
PostgreSQL Database directory appears to contain a database; Skipping initialization

FATAL: database files are incompatible with server
DETAIL: The data directory was initialized by PostgreSQL version 17, which is not
compatible with this version 16.15 (Debian 16.15-1.pgdg13+2).

This is the real-world shape of the rollback danger described earlier. The image went backwards; the data on disk did not.

Step 9 — Discover That Re-Applying Does Not Help

The instinctive fix is to put the working image back and apply again. Try it:

spec:
containers:
- name: postgres
image: postgres:17.4
kubectl apply -f 4-chat-history-statefulset.yaml
kubectl get pods -n chatops -l app=chat-history -w

A full minute later, nothing has improved:

t+06s chat-history-2(0/1,CrashLoopBackOff) img=postgres:16
t+30s chat-history-2(0/1,CrashLoopBackOff) img=postgres:16
t+60s chat-history-2(0/1,CrashLoopBackOff) img=postgres:16

The Pod is still on postgres:16. The StatefulSet accepted the new template but will not act on it, because it is waiting for chat-history-2 to become ready first. The Pod cannot become ready. The rollout is deadlocked.

Step 10 — Recover With Manual Intervention

Delete the failed Pod yourself. That is the only way out:

kubectl delete pod chat-history-2 -n chatops
kubectl wait --for=condition=Ready pod/chat-history-2 -n chatops --timeout=300s

The replacement is created from the current template and starts normally:

chat-history-0 postgres:17.4
chat-history-1 postgres:17.4
chat-history-2 postgres:17.4
kubectl rollout status statefulset chat-history -n chatops
partitioned roll out complete: 3 new pods have been updated...

The data on chat-history-0 was never at risk, because the failure never reached that replica. Confirm it:

kubectl exec chat-shell -n chatops -- psql -h chat-history-0.chat-history-headless -c "SELECT * FROM messages;"
id | body
----+-----------------
1 | written on 17.0
(1 row)

This is a good argument for canary releases on stateful workloads. Had we rolled out the bad image with partition at 3 and stepped down carefully, the damage would have been identical: contained to one replica, with the primary untouched.

Step 11 — The OnDelete Strategy

Switch strategies to see the manual mode. Contrary to some older documentation, the updateStrategy field can be changed on a live StatefulSet in current Kubernetes versions; you do not have to delete and recreate the object.

kubectl patch statefulset chat-history -n chatops --type=merge \
-p '{"spec":{"updateStrategy":{"type":"OnDelete","rollingUpdate":null}}}'
kubectl get sts chat-history -n chatops -o jsonpath='{.spec.updateStrategy}'
{"type":"OnDelete"}

Now change the image and watch nothing happen:

kubectl set image statefulset chat-history -n chatops postgres=postgres:17.2
chat-history-0 postgres:17.4 Running
chat-history-1 postgres:17.4 Running
chat-history-2 postgres:17.4 Running

The StatefulSet's template has changed, but no Pod has:

kubectl get sts chat-history -n chatops -o jsonpath='{.spec.template.spec.containers[0].image}'
postgres:17.2

Delete a single Pod and only that one picks up the new template:

kubectl delete pod chat-history-1 -n chatops
kubectl wait --for=condition=Ready pod/chat-history-1 -n chatops --timeout=300s
chat-history-0 postgres:17.4
chat-history-1 postgres:17.2
chat-history-2 postgres:17.4

You are now in complete control of the pace, and you can run any verification you like between replicas. One consequence to know about: kubectl rollout status does not work under this strategy.

kubectl rollout status statefulset chat-history -n chatops
error: rollout status is only available for RollingUpdate strategy type

Switch back to RollingUpdate, and the controller immediately brings the remaining Pods to the template version:

kubectl patch statefulset chat-history -n chatops --type=merge \
-p '{"spec":{"updateStrategy":{"type":"RollingUpdate","rollingUpdate":{"partition":0}}}}'
kubectl rollout status statefulset chat-history -n chatops

Step 12 — Revisions and Rollback

Every template change stores a revision:

kubectl rollout history statefulset chat-history -n chatops
kubectl get controllerrevision -n chatops -l app=chat-history
REVISION CHANGE-CAUSE
1 <none>
4 <none>
5 <none>
6 <none>

Roll back to the previous revision:

kubectl rollout undo statefulset chat-history -n chatops

Kubernetes hands you an important warning:

Warning: resource statefulsets/chat-history was previously managed with 'kubectl apply'.
Rolling back will not update the kubectl.kubernetes.io/last-applied-configuration annotation,
which may cause unexpected behavior on future 'kubectl apply' operations.
Consider using 'kubectl apply' with your previous configuration file instead.
statefulset.apps/chat-history rolled back

Read that last sentence again. Kubernetes itself is telling you to prefer the declarative route. The rollback works, and the Pods return to the previous image in the usual reverse order:

chat-history-0 postgres:17.4
chat-history-1 postgres:17.4
chat-history-2 postgres:17.4

And the message written back on 17.0 has survived every one of these operations:

id | body
----+-----------------
1 | written on 17.0
(1 row)

In a real project you would not run rollout undo at all. You would revert the commit that changed the image, let the pipeline apply the manifest, and keep the cluster and the repository in agreement.

Step 13 — Clean Up

One command removes the Pods, the Service, the Secret, the StatefulSet, and the claims.

kubectl delete namespace chatops
kubectl get pv

Check that no volumes were left behind.

Summary

In this article you learned how to release a new version of a stateful application:

  1. Changing .spec.template starts a rollout, exactly as with a Deployment.
  2. RollingUpdate replaces Pods in reverse ordinal order, one at a time, waiting for each to be Ready. Ordinal 0 is updated last.
  3. OnDelete updates the template but recreates nothing until you delete a Pod yourself. Use it when a manual check belongs between replicas.
  4. partition protects every ordinal below it. Set it equal to replicas to stage a rollout, then step it down for a canary and phased release.
  5. A failed rollout stalls. Re-applying the working manifest does not fix it. You must delete the failed Pod manually.
  6. kubectl rollout status only works under RollingUpdate.
  7. updateStrategy can be changed on a live StatefulSet in current Kubernetes versions.
  8. Prefer declarative rollbacks: revert the commit and apply. Kubernetes itself warns against rollout undo on objects managed with apply.
  9. Downgrading an image does not downgrade your data. Without a downward migration, roll forward with a fix instead of backward to the old version.

In the next article we will gather the practical guidance from this whole chapter into a set of StatefulSet best practices.