Managing StatefulSets — Network Identity, Scaling, and Deletion

Lesson 38 of 39 24 min read Updated

In the earlier articles of this chapter you built a StatefulSet and learned that Kubernetes manages Pod identity and storage, but never your data. This article covers the day-to-day operations: how the headless Service actually produces stable network names, what happens to your data when Pods are destroyed, how scaling behaves differently from a Deployment, and how to delete a StatefulSet without losing or leaking storage.

Everything here is demonstrated with experiments you can repeat. The most important one takes about thirty seconds: we create data in a Pod, delete that Pod, and reconnect using the exact same name. The Pod's IP address changes. The name and the data do not.

We will cover the following topics:

  1. How a headless Service produces DNS A records and per-Pod names
  2. Why load balancing becomes the client's job
  3. Two real use cases: clustered databases and data sharding
  4. State persistence when Pods are deleted
  5. Ordered scaling, and how to relax it with podManagementPolicy
  6. Deleting a StatefulSet with and without its Pods
  7. Controlling PVC cleanup with persistentVolumeClaimRetentionPolicy

Technical Requirements

  1. A running Kubernetes cluster with a default StorageClass. Docker Desktop, minikube, or a cloud cluster will all work. A multi-node cluster shows the concepts best.
  2. kubectl installed and configured.
  3. Docker and the .NET 10 SDK if you want to build the sample router image yourself.

Core Concepts

What a Headless Service Really Publishes

A normal Service gets a ClusterIP: a single virtual IP address. Traffic sent to that IP is load balanced across the backing Pods. On Linux this happens at the kernel level, through iptables or IPVS rules that kube-proxy maintains. The client sees one address and never knows which Pod answered.

A headless Service sets clusterIP: None, so there is no virtual IP and no kube-proxy involvement at all. Instead, DNS returns the Pod IP addresses directly. Two kinds of record are published:

  1. One A record per ready Pod, all sharing the Service's own name. A lookup of metrics-store-headless returns three addresses when three Pods are ready.
  2. One per-Pod name in the form <podName>.<headlessServiceName>.<namespace>.svc.cluster.local. This is the record that gives each replica its stable identity.

The consequence is important: with a headless Service, choosing a target Pod becomes the client's responsibility. Kubernetes hands you a list of addresses and steps back. Some clients take the first address and stay there; others rotate. Nothing balances the load for you.


ClusterIP ServiceHeadless Service
DNS answerOne virtual IPEvery ready Pod IP
Load balancingkube-proxy, in the kernelThe client decides
Per-Pod namesNone<pod>.<service>.<ns>.svc.cluster.local
Typical useStateless replicas behind one addressStatefulSets, where replicas are not interchangeable

Why Stable Names Matter

A beginner reasonably asks: why go to all this trouble? Two concrete situations make it necessary.

Clustered databases. Systems such as etcd, MongoDB, or Cassandra need each member to be told the network addresses of the other members. Many of them have no automatic discovery at all. Stable DNS names let you write that member list once and have it stay correct across restarts. There is still a problem when replicas are added or removed during scaling, and this is often solved with a sidecar container that watches the Kubernetes API and rewrites the database configuration as membership changes.

Data sharding. If you split data across replicas, you need a mapping from a logical shard to a physical Pod. Stable DNS names are the natural key for that mapping. Shard 2 always means metrics-store-2, whether that Pod was just rescheduled onto a different node or has been running for a month. We will build exactly this in the hands-on section.

State Persistence

When a StatefulSet Pod is deleted, the controller creates a replacement with the same ordinal name, and that replacement re-attaches the same PersistentVolumeClaim. The container is disposable; the volume is not.

This is true whether the Pod died on its own, was evicted, or you deleted it by hand. It also holds when you delete every Pod at once: Kubernetes recreates them in ascending order, each with its original claim.

The flip side is that the StatefulSet controller will never remove a PersistentVolume as part of deleting a Pod or the StatefulSet itself. Cleaning up storage stays your job, unless you configure the retention policy described later in this article.

Ordered Scaling

Scaling a StatefulSet uses the same tools as a Deployment: edit replicas in the manifest, or run kubectl scale. New Pods are automatically added to the Service's endpoints, and removed when scaled down. The ordering, however, is strict.

  1. Deploying N replicas creates Pods sequentially, from ordinal 0 to N-1.
  2. Scaling up creates the new Pods sequentially too.
  3. Scaling down terminates Pods in reverse order, from N-1 down to 0.
  4. Before the next Pod is created, every predecessor must be Running and Ready.
  5. Before the next Pod is terminated, every successor must be completely terminated and deleted.

There is a consequence worth thinking about. Because every scaling operation waits for the predecessors to be ready, a single unhealthy Pod blocks the whole queue. If metrics-store-0 fails while you are scaling down from four replicas to one, no further scaling happens to any of the other Pods. Work resumes only when metrics-store-0 becomes ready again.

You can relax this with the .spec.podManagementPolicy field. The default is OrderedReady. Setting it to Parallel makes creation and deletion happen all at once, like a Deployment. Note two limits: this affects only scaling, not rolling updates, and the field is immutable, so you must decide when you create the StatefulSet.

One more scaling note. If you manage a StatefulSet with a Horizontal Pod Autoscaler or a similar tool, leave .spec.replicas out of your manifest entirely. If you set it, every re-apply of the manifest fights the autoscaler and resets the replica count.

Deleting a StatefulSet

There are two ways to delete a StatefulSet, and they differ in what happens to the Pods.

  1. Cascading delete (the default) removes the StatefulSet and its Pods. The Pods are terminated first, then the object disappears. Important: this is not the same as scaling to zero and then deleting. In a cascading delete there is no guarantee about termination order, and in practice the Pods usually go down all at once. For a graceful, ordered shutdown, scale to zero first.
  2. Orphan delete, with --cascade=orphan, removes only the StatefulSet object. Every Pod keeps running, unmanaged. This is useful during maintenance when you want to replace the controller without disturbing the workload.

In both cases the PVCs and PVs created from volumeClaimTemplates survive by default, so state data is never lost by accident.

Since Kubernetes v1.27 you can change that default with .spec.persistentVolumeClaimRetentionPolicy, which has two independent settings:

spec:
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Delete

whenDeleted controls what happens when the StatefulSet itself is deleted; whenScaled controls what happens to the claims of Pods removed by scaling down. Each accepts Retain (the default) or Delete. The combination above is a common, sensible choice: automatically reclaim storage from replicas you scaled away, but never lose everything because someone deleted the wrong object.

Hands-On: Kubernetes Commands

Resolve the headless Service

Returns one address per ready Pod. The getent tool is available in most Linux images.

kubectl exec metrics-shell -n metrics -- getent hosts metrics-store-headless

Resolve a single Pod by its stable name

Returns exactly one address, no matter how many replicas exist.

kubectl exec metrics-shell -n metrics -- getent hosts metrics-store-0.metrics-store-headless

Scale a StatefulSet

Works imperatively, or by editing replicas in the manifest and re-applying.

kubectl scale statefulset -n metrics metrics-store --replicas 5

Read the controller's events

Shows each Pod and each claim being created, in order, with timestamps.

kubectl describe sts -n metrics metrics-store

See which claim each Pod holds

Filtering the describe output makes the Pod-to-claim mapping easy to read at a glance.

kubectl describe pod -n metrics -l app=metrics-store | egrep 'ClaimName|^Name:'

Delete the StatefulSet but keep the Pods

The Pods keep running with no controller watching them.

kubectl delete sts -n metrics metrics-store --cascade=orphan

Change the retention policy on a live StatefulSet

Unlike podManagementPolicy, this field can be changed after creation.

kubectl patch statefulset metrics-store -n metrics --type=merge \
-p '{"spec":{"persistentVolumeClaimRetentionPolicy":{"whenDeleted":"Retain","whenScaled":"Delete"}}}'

Clean up leftover claims

The StatefulSet controller copies the selector labels onto every claim it creates, so a label selector deletes them all without typing each name.

kubectl delete pvc -n metrics -l app=metrics-store

Step-by-Step Example

The Scenario

We will run a Metrics system in its own namespace. A PostgreSQL StatefulSet named metrics-store holds sensor readings, split across three shards. A metrics-shell Pod gives us a command line for experiments. An ASP.NET Core service named shard-router demonstrates the sharding use case: it maps each sensor name to a shard number, then talks to that shard through its stable per-Pod DNS name.

Step 1 — Create the Namespace, Secret, and Schema

The namespace keeps the exercise isolated and makes cleanup a single command.

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

The credentials decode to metricsadmin and M3tr1csP@ss2026.

apiVersion: v1
kind: Secret
metadata:
name: metrics-store-secret
namespace: metrics
type: Opaque
data:
POSTGRES_USER: bWV0cmljc2FkbWlu
POSTGRES_PASSWORD: TTN0cjFjc1BAc3MyMDI2
kubectl apply -f 2-metrics-store-secret.yaml

This ConfigMap is mounted into the database image's initialization directory, so every replica creates the same empty readings table when it first starts.

apiVersion: v1
kind: ConfigMap
metadata:
name: metrics-store-schema
namespace: metrics
data:
001-create-readings.sql: |
CREATE TABLE IF NOT EXISTS readings (
id SERIAL PRIMARY KEY,
sensor VARCHAR(80) NOT NULL,
value NUMERIC(10,2) NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS readings_sensor_idx ON readings (sensor);
kubectl apply -f 3-metrics-store-schema-configmap.yaml

Step 2 — Create the Headless Service

clusterIP: None is what makes this Service headless. It must exist before the StatefulSet, because .spec.serviceName points at it.

apiVersion: v1
kind: Service
metadata:
name: metrics-store-headless
namespace: metrics
labels:
app: metrics-store
spec:
clusterIP: None
selector:
app: metrics-store
ports:
- name: postgres
port: 5432
targetPort: 5432
kubectl apply -f 4-metrics-store-headless-service.yaml

Step 3 — Create the StatefulSet

Two fields here are new compared with the previous articles. podManagementPolicy is set to its default OrderedReady explicitly, so you can see it and compare it with the Parallel example later. persistentVolumeClaimRetentionPolicy is also written out with its defaults, so the behaviour is documented in the manifest rather than implied.

apiVersion: apps/v1
kind: StatefulSet
metadata:
name: metrics-store
namespace: metrics
labels:
app: metrics-store
spec:
serviceName: metrics-store-headless
replicas: 3
podManagementPolicy: OrderedReady
persistentVolumeClaimRetentionPolicy:
whenDeleted: Retain
whenScaled: Retain
selector:
matchLabels:
app: metrics-store
template:
metadata:
labels:
app: metrics-store
spec:
containers:
- name: postgres
image: postgres:17
ports:
- name: postgres
containerPort: 5432
env:
- name: POSTGRES_DB
value: metricsdb
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: metrics-store-secret
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: metrics-store-secret
key: POSTGRES_PASSWORD
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: metrics-data
mountPath: /var/lib/postgresql/data
- name: schema-scripts
mountPath: /docker-entrypoint-initdb.d
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
volumes:
- name: schema-scripts
configMap:
name: metrics-store-schema
volumeClaimTemplates:
- metadata:
name: metrics-data
spec:
accessModes:
- "ReadWriteOnce"
resources:
requests:
storage: 1Gi
kubectl apply -f 5-metrics-store-statefulset.yaml
kubectl rollout status statefulset metrics-store -n metrics

Step 4 — Create the Shell Pod

We reuse the postgres:17 image as a client and keep it alive with sleep infinity. The PG* variables are read automatically by psql, so credentials never appear in our commands.

apiVersion: v1
kind: Pod
metadata:
name: metrics-shell
namespace: metrics
labels:
app: metrics-shell
spec:
containers:
- name: shell
image: postgres:17
command:
- sleep
- infinity
env:
- name: PGUSER
valueFrom:
secretKeyRef:
name: metrics-store-secret
key: POSTGRES_USER
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: metrics-store-secret
key: POSTGRES_PASSWORD
- name: PGDATABASE
value: metricsdb
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"
restartPolicy: Always
kubectl apply -f 6-metrics-shell-pod.yaml

Step 5 — Explore the DNS Records

Ask DNS for the headless Service name. You get one address per ready Pod:

kubectl exec metrics-shell -n metrics -- getent hosts metrics-store-headless
10.244.1.34 metrics-store-headless.metrics.svc.cluster.local
10.244.2.40 metrics-store-headless.metrics.svc.cluster.local
10.244.2.38 metrics-store-headless.metrics.svc.cluster.local

Three A records, pointing straight at Pod IPs. Compare this with a ClusterIP Service, which would return a single virtual address. Here there is nothing to load balance through, which is exactly why the client must choose.

Now ask for each Pod by its own name:

for p in 0 1 2; do
kubectl exec metrics-shell -n metrics -- getent hosts metrics-store-$p.metrics-store-headless
done
10.244.2.38 metrics-store-0.metrics-store-headless.metrics.svc.cluster.local
10.244.1.34 metrics-store-1.metrics-store-headless.metrics.svc.cluster.local
10.244.2.40 metrics-store-2.metrics-store-headless.metrics.svc.cluster.local

Each name resolves to exactly one Pod. Note that the addresses match the three from the Service lookup, just individually addressable now.

If you prefer the classic nslookup output, run it from a busybox Pod. Use the fully qualified name, because the nslookup in recent busybox images does not apply the cluster's DNS search list reliably:

kubectl run dnsprobe -n metrics --rm -it --restart=Never --image=busybox:1.37 -- \
nslookup metrics-store-headless.metrics.svc.cluster.local
Name: metrics-store-headless.metrics.svc.cluster.local
Address: 10.244.2.40
Name: metrics-store-headless.metrics.svc.cluster.local
Address: 10.244.2.38
Name: metrics-store-headless.metrics.svc.cluster.local
Address: 10.244.1.34

Step 6 — The Key Experiment: The Name Outlives the Pod

First, note the current IP address of metrics-store-0:

kubectl get pod metrics-store-0 -n metrics -o jsonpath='{.status.podIP}'
10.244.2.38

Now write some data through the stable DNS name, and create a database so we have something obvious to look for later:

kubectl exec metrics-shell -n metrics -- psql -h metrics-store-0.metrics-store-headless \
-c "INSERT INTO readings (sensor, value) VALUES ('boiler-temp', 84.50);"
kubectl exec metrics-shell -n metrics -- psql -h metrics-store-0.metrics-store-headless \
-c "CREATE DATABASE ststest;"
INSERT 0 1
CREATE DATABASE

Delete the Pod and wait for its replacement:

kubectl delete pod metrics-store-0 -n metrics
kubectl wait --for=condition=Ready pod/metrics-store-0 -n metrics --timeout=240s

Check the IP address again:

kubectl get pod metrics-store-0 -n metrics -o jsonpath='{.status.podIP}'
10.244.2.41

The address changed. Now resolve the name:

kubectl exec metrics-shell -n metrics -- getent hosts metrics-store-0.metrics-store-headless
10.244.2.41 metrics-store-0.metrics-store-headless.metrics.svc.cluster.local

The same name now points at the new address, automatically. Any client that stored the name rather than the IP keeps working. Finally, confirm the data came back with the volume:

kubectl exec metrics-shell -n metrics -- psql -h metrics-store-0.metrics-store-headless \
-c "SELECT sensor, value FROM readings;"
kubectl exec metrics-shell -n metrics -- psql -h metrics-store-0.metrics-store-headless -l
sensor | value
-------------+-------
boiler-temp | 84.50
(1 row)

Name | Owner | Encoding
-----------+--------------+----------
metricsdb | metricsadmin | UTF8
postgres | metricsadmin | UTF8
ststest | metricsadmin | UTF8

Both the row and the ststest database survived. The Pod was destroyed and rebuilt, but its identity, its name, and its storage all came back together.

Step 7 — Delete Every Pod at Once

The same guarantees hold when you remove the whole set:

kubectl delete pod -n metrics metrics-store-0 metrics-store-1 metrics-store-2
kubectl rollout status statefulset metrics-store -n metrics

Kubernetes recreates them in ascending order. Check the Pod-to-claim mapping afterwards:

kubectl describe pod -n metrics -l app=metrics-store | egrep 'ClaimName|^Name:'
Name: metrics-store-0
ClaimName: metrics-data-metrics-store-0
Name: metrics-store-1
ClaimName: metrics-data-metrics-store-1
Name: metrics-store-2
ClaimName: metrics-data-metrics-store-2

Every Pod found its original claim, and the data is still there:

kubectl exec metrics-shell -n metrics -- psql -h metrics-store-0.metrics-store-headless -tAX \
-c "SELECT sensor, value FROM readings;"
boiler-temp|84.50

Step 8 — Build the Shard Router

Now let's use those stable names for something real. The router maps a sensor name to a shard number, then connects to that shard by its per-Pod DNS name. Create the project:

dotnet new web -n ShardRouter
cd ShardRouter
dotnet add package Npgsql

Replace Program.cs with the following:

using System.Text;
using Npgsql;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

int shardCount = int.Parse(Environment.GetEnvironmentVariable("SHARD_COUNT") ?? "3");
string hostPattern = Environment.GetEnvironmentVariable("SHARD_HOST_PATTERN") ?? "localhost";
string database = Environment.GetEnvironmentVariable("SHARD_DB_NAME") ?? "postgres";
string user = Environment.GetEnvironmentVariable("SHARD_DB_USER") ?? "postgres";
string password = Environment.GetEnvironmentVariable("SHARD_DB_PASSWORD") ?? "";

string HostForShard(int shard) => string.Format(hostPattern, shard);

string ConnectionFor(int shard) =>
$"Host={HostForShard(shard)};Port=5432;Database={database};" +
$"Username={user};Password={password};Pooling=false";

// A deterministic shard function. String.GetHashCode() is randomised per process,
// so it must never be used for routing that has to stay stable across restarts.
int ShardFor(string key)
{
int sum = 0;
foreach (byte b in Encoding.UTF8.GetBytes(key))
{
sum = (sum + b) % 100003;
}
return sum % shardCount;
}

app.MapGet("/healthz", () => Results.Ok("healthy"));

app.MapGet("/shards", async () =>
{
var shards = new List<object>();
for (int i = 0; i < shardCount; i++)
{
string host = HostForShard(i);
try
{
await using var connection = new NpgsqlConnection(ConnectionFor(i));
await connection.OpenAsync();
await using var command = new NpgsqlCommand(
"SELECT inet_server_addr()::text, (SELECT count(*) FROM readings)", connection);
await using var reader = await command.ExecuteReaderAsync();
await reader.ReadAsync();
shards.Add(new { Shard = i, Host = host, ServerIp = reader.GetString(0), Readings = reader.GetInt64(1) });
}
catch (Exception ex)
{
shards.Add(new { Shard = i, Host = host, ServerIp = "unreachable", Error = ex.GetType().Name });
}
}
return Results.Ok(shards);
});

app.MapPost("/readings/{sensor}/{value}", async (string sensor, decimal value) =>
{
int shard = ShardFor(sensor);
await using var connection = new NpgsqlConnection(ConnectionFor(shard));
await connection.OpenAsync();
await using var command = new NpgsqlCommand(
"INSERT INTO readings (sensor, value) VALUES (@sensor, @value)", connection);
command.Parameters.AddWithValue("sensor", sensor);
command.Parameters.AddWithValue("value", value);
await command.ExecuteNonQueryAsync();
return Results.Ok(new { Sensor = sensor, Shard = shard, Host = HostForShard(shard), Stored = value });
});

app.MapGet("/readings/{sensor}", async (string sensor) =>
{
int shard = ShardFor(sensor);
await using var connection = new NpgsqlConnection(ConnectionFor(shard));
await connection.OpenAsync();
await using var command = new NpgsqlCommand(
"SELECT value, recorded_at FROM readings WHERE sensor = @sensor ORDER BY id", connection);
command.Parameters.AddWithValue("sensor", sensor);
await using var reader = await command.ExecuteReaderAsync();

var values = new List<object>();
while (await reader.ReadAsync())
{
values.Add(new { Value = reader.GetDecimal(0), RecordedAt = reader.GetDateTime(1) });
}
return Results.Ok(new { Sensor = sensor, Shard = shard, Host = HostForShard(shard), Values = values });
});

app.Run();

Read the comment above ShardFor carefully. In .NET, String.GetHashCode() returns a different value in every process, by design. Using it to pick a shard would send the same sensor to a different Pod after every restart, and your reads would silently miss the data. Sharding needs a hash you control.

Add a Dockerfile next to the project file:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish ShardRouter.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=5090
EXPOSE 5090
ENTRYPOINT ["dotnet", "ShardRouter.dll"]
docker build -t shard-router:1.0 .

On Docker Desktop the cluster shares the local image store, so the image is usable at once. On minikube run minikube image load shard-router:1.0. On a cloud cluster, push to a registry and update the image name below.

The Deployment passes the DNS pattern as an environment variable. The {0} placeholder is replaced with the shard number, producing metrics-store-0.metrics-store-headless and so on.

apiVersion: apps/v1
kind: Deployment
metadata:
name: shard-router
namespace: metrics
labels:
app: shard-router
spec:
replicas: 1
selector:
matchLabels:
app: shard-router
template:
metadata:
labels:
app: shard-router
spec:
containers:
- name: shard-router
image: shard-router:1.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 5090
env:
- name: SHARD_COUNT
value: "3"
- name: SHARD_HOST_PATTERN
value: "metrics-store-{0}.metrics-store-headless"
- name: SHARD_DB_NAME
value: metricsdb
- name: SHARD_DB_USER
valueFrom:
secretKeyRef:
name: metrics-store-secret
key: POSTGRES_USER
- name: SHARD_DB_PASSWORD
valueFrom:
secretKeyRef:
name: metrics-store-secret
key: POSTGRES_PASSWORD
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
readinessProbe:
httpGet:
path: /healthz
port: 5090
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz
port: 5090
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 3
kubectl apply -f 7-shard-router-deployment.yaml
kubectl rollout status deployment shard-router -n metrics

Step 9 — Watch Sharding in Action

Forward a port and write readings for four different sensors:

kubectl port-forward deployment/shard-router -n metrics 5090:5090
for s in boiler-temp pump-pressure valve-position intake-flow; do
curl -s -X POST http://localhost:5090/readings/$s/42.5
echo
done
{"sensor":"boiler-temp","shard":1,"host":"metrics-store-1.metrics-store-headless","stored":42.5}
{"sensor":"pump-pressure","shard":1,"host":"metrics-store-1.metrics-store-headless","stored":42.5}
{"sensor":"valve-position","shard":2,"host":"metrics-store-2.metrics-store-headless","stored":42.5}
{"sensor":"intake-flow","shard":2,"host":"metrics-store-2.metrics-store-headless","stored":42.5}

Each sensor was routed to a specific Pod by name. Ask the router to describe all shards:

curl -s http://localhost:5090/shards
[
{ "shard": 0, "host": "metrics-store-0.metrics-store-headless", "serverIp": "10.244.2.42/32", "readings": 1 },
{ "shard": 1, "host": "metrics-store-1.metrics-store-headless", "serverIp": "10.244.1.36/32", "readings": 2 },
{ "shard": 2, "host": "metrics-store-2.metrics-store-headless", "serverIp": "10.244.2.43/32", "readings": 2 }
]

Shard 0 holds one reading: the boiler-temp row we inserted by hand in Step 6, straight into metrics-store-0. The router's hash happens to place the sensor boiler-temp on shard 1, which is why the two do not line up. That is a useful reminder that the shard mapping lives in your application, not in Kubernetes.

Now the real test. Note the address of the Pod holding shard 2, then destroy it:

kubectl get pod metrics-store-2 -n metrics -o jsonpath='{.status.podIP}'
kubectl delete pod metrics-store-2 -n metrics
kubectl wait --for=condition=Ready pod/metrics-store-2 -n metrics --timeout=240s
kubectl get pod metrics-store-2 -n metrics -o jsonpath='{.status.podIP}'
10.244.2.43 <- before
10.244.2.44 <- after

Query the same sensor again, with no configuration change and no restart of the router:

curl -s http://localhost:5090/readings/valve-position
{
"sensor": "valve-position",
"shard": 2,
"host": "metrics-store-2.metrics-store-headless",
"values": [ { "value": 42.5, "recordedAt": "2026-09-06T13:51:46.986973Z" } ]
}

The Pod's IP address changed, and the reading was still found, down to the original timestamp. This is the sharding use case working exactly as promised: the mapping from logical shard to physical Pod stays correct across a restart because it is expressed as a name, not an address.

Step 10 — Ordered Scaling Up

Scale from three replicas to five and watch closely:

kubectl scale statefulset -n metrics metrics-store --replicas 5
kubectl get pods -n metrics -l app=metrics-store -w

Sampling the Pod list every three seconds makes the ordering unmistakable:

t+03s metrics-store-0(1/1) metrics-store-1(1/1) metrics-store-2(1/1) metrics-store-3(0/1)
t+15s metrics-store-0(1/1) metrics-store-1(1/1) metrics-store-2(1/1) metrics-store-3(0/1)
t+18s metrics-store-0(1/1) metrics-store-1(1/1) metrics-store-2(1/1) metrics-store-3(1/1) metrics-store-4(0/1)
t+33s metrics-store-0(1/1) metrics-store-1(1/1) metrics-store-2(1/1) metrics-store-3(1/1) metrics-store-4(1/1)

Look at the third line. metrics-store-4 does not even exist until metrics-store-3 reports 1/1. A Deployment would have created both immediately.

The controller's events tell the same story, and show that a claim is created before each new Pod:

kubectl describe sts -n metrics metrics-store
Events:
Normal SuccessfulCreate 39s statefulset-controller Create Claim metrics-data-metrics-store-3 Pod metrics-store-3 in StatefulSet metrics-store success
Normal SuccessfulCreate 39s statefulset-controller Create Pod metrics-store-3 in StatefulSet metrics-store successful
Normal SuccessfulCreate 24s statefulset-controller Create Claim metrics-data-metrics-store-4 Pod metrics-store-4 in StatefulSet metrics-store success
Normal SuccessfulCreate 24s statefulset-controller Create Pod metrics-store-4 in StatefulSet metrics-store successful

Step 11 — Ordered Scaling Down

Now scale back to two replicas:

kubectl scale statefulset -n metrics metrics-store --replicas 2
kubectl get pods -n metrics -l app=metrics-store -w
t+03s metrics-store-0(Running) metrics-store-1(Running) metrics-store-2(Running) metrics-store-3(Running) metrics-store-4(Terminating)
t+06s metrics-store-0(Running) metrics-store-1(Running)

The highest ordinal goes first. Termination walks backwards down to the target count, and metrics-store-0 is never touched.

Check the claims:

kubectl get pvc -n metrics
metrics-data-metrics-store-0 Bound
metrics-data-metrics-store-1 Bound
metrics-data-metrics-store-2 Bound
metrics-data-metrics-store-3 Bound
metrics-data-metrics-store-4 Bound

All five survive, because our manifest sets whenScaled: Retain. Two Pods are running and five volumes are being paid for. This is the expected default, and it is why storage cleanup deserves your attention.

Step 12 — Automatic Claim Cleanup on Scale-Down

Change the retention policy so that scaling down also removes the claims. Unlike podManagementPolicy, this field can be patched on a live StatefulSet:

kubectl patch statefulset metrics-store -n metrics --type=merge \
-p '{"spec":{"persistentVolumeClaimRetentionPolicy":{"whenDeleted":"Retain","whenScaled":"Delete"}}}'

Scale from two replicas down to one and check the claims again:

kubectl scale statefulset -n metrics metrics-store --replicas 1
kubectl get pvc -n metrics
metrics-data-metrics-store-0 Bound
metrics-data-metrics-store-2 Bound
metrics-data-metrics-store-3 Bound
metrics-data-metrics-store-4 Bound

metrics-data-metrics-store-1 is gone, deleted automatically with its Pod. The claims for ordinals 2, 3, and 4 remain, because they were already orphaned back when the policy still said Retain. Changing the policy does not reach backwards and clean up history.

Choose this setting deliberately. Delete keeps your storage bill honest, but a scale-down then destroys data permanently and immediately.

Step 13 — Deleting Without Touching the Pods

Scale back to three replicas, then delete the controller only:

kubectl scale statefulset -n metrics metrics-store --replicas 3
kubectl rollout status statefulset metrics-store -n metrics
kubectl delete sts -n metrics metrics-store --cascade=orphan

The StatefulSet is gone, but every Pod is untouched. Look at the ages:

kubectl get sts -n metrics
kubectl get pods -n metrics -l app=metrics-store
No resources found in metrics namespace.

NAME READY STATUS RESTARTS AGE
metrics-store-0 1/1 Running 0 3m18s
metrics-store-1 1/1 Running 0 25s
metrics-store-2 1/1 Running 0 12s

Re-create the StatefulSet from the same manifest. It adopts the running Pods rather than replacing them:

kubectl apply -f 5-metrics-store-statefulset.yaml
kubectl get sts -n metrics
kubectl get pods -n metrics -l app=metrics-store
NAME READY AGE
metrics-store 3/3 5s

metrics-store-0 1/1 3m23s
metrics-store-1 1/1 30s
metrics-store-2 1/1 17s

The StatefulSet is five seconds old while the Pods are minutes old. Nothing restarted. Adoption works because the selector labels still match.

Step 14 — Cascading Delete and Manual Cleanup

Now the ordinary delete, which takes the Pods with it:

kubectl delete sts -n metrics metrics-store
kubectl get pods -n metrics -l app=metrics-store
No resources found in metrics namespace.

Remember that this gives no ordering guarantee. The Pods generally go down together. When ordered shutdown matters, scale to zero first and only then delete.

The claims, however, are still there, because our manifest sets whenDeleted: Retain:

kubectl get pvc -n metrics
metrics-data-metrics-store-0 Bound
metrics-data-metrics-store-1 Bound
metrics-data-metrics-store-2 Bound
metrics-data-metrics-store-3 Bound
metrics-data-metrics-store-4 Bound

Deleting them is a deliberate, manual act. The label selector works because the controller copies the StatefulSet's selector labels onto every claim it creates:

kubectl delete pvc -n metrics -l app=metrics-store

Deleting a claim also releases its PersistentVolume. With a Delete reclaim policy the volume disappears; with Retain it stays behind in a Released state for you to remove.

One warning before you run this. If you plan to work through the next article on rolling out new versions, keep these claims. Deleting them now destroys the database files you would use to verify that state survives an upgrade.

Step 15 — Optional: Parallel Pod Management

To see how different Parallel feels, apply this second, independent StatefulSet. It runs Redis, needs no storage, and exists purely for the contrast. The file contains both the headless Service and the StatefulSet.

apiVersion: v1
kind: Service
metadata:
name: parallel-cache-headless
namespace: metrics
labels:
app: parallel-cache
spec:
clusterIP: None
selector:
app: parallel-cache
ports:
- name: redis
port: 6379
targetPort: 6379
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: parallel-cache
namespace: metrics
labels:
app: parallel-cache
spec:
serviceName: parallel-cache-headless
replicas: 4
podManagementPolicy: Parallel
selector:
matchLabels:
app: parallel-cache
template:
metadata:
labels:
app: parallel-cache
spec:
containers:
- name: redis
image: redis:7.4
ports:
- name: redis
containerPort: 6379
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"
readinessProbe:
exec:
command:
- redis-cli
- ping
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
kubectl apply -f 8-parallel-cache-statefulset.yaml
kubectl get pods -n metrics -l app=parallel-cache -w
t+03s parallel-cache-0(0/1) parallel-cache-1(0/1) parallel-cache-2(0/1) parallel-cache-3(0/1)
t+21s parallel-cache-0(0/1) parallel-cache-1(0/1) parallel-cache-2(0/1) parallel-cache-3(0/1)
t+24s parallel-cache-0(1/1) parallel-cache-1(1/1) parallel-cache-2(0/1) parallel-cache-3(1/1)
t+27s parallel-cache-0(1/1) parallel-cache-1(1/1) parallel-cache-2(1/1) parallel-cache-3(1/1)

All four Pods exist three seconds in, and they become ready in whatever order they finish. Compare this with Step 10, where the fourth Pod had not been created yet after fifteen seconds. Note that the Pods still get ordered names and their own claims. Only the timing changed.

Step 16 — Clean Up

Deleting the namespace removes everything in one command.

kubectl delete namespace metrics
kubectl get pv

Confirm no volumes were left behind.

Summary

In this article you learned how to operate a StatefulSet day to day:

  1. A headless Service publishes one A record per ready Pod plus a per-Pod name of the form <pod>.<service>.<namespace>.svc.cluster.local.
  2. There is no kube-proxy load balancing behind a headless Service. The client chooses the target.
  3. A Pod's IP address changes when it is recreated. Its DNS name and its volume do not.
  4. Stable names make clustered databases and data sharding practical, because a shard-to-Pod mapping stays valid across restarts.
  5. Scaling is ordered: up from 0, down in reverse, each step waiting for the previous Pod. A single unhealthy Pod stalls the queue.
  6. podManagementPolicy: Parallel removes that ordering for scaling only. It is immutable after creation.
  7. Leave replicas unset when a Horizontal Pod Autoscaler manages the StatefulSet.
  8. --cascade=orphan deletes the controller and leaves the Pods running, and re-applying the manifest adopts them.
  9. A cascading delete gives no termination order guarantee. Scale to zero first for a graceful shutdown.
  10. persistentVolumeClaimRetentionPolicy controls claim cleanup separately for whenDeleted and whenScaled. Both default to Retain.

In the next article we will release a new version of an application running as a StatefulSet, and look at how revisions and update strategies control those ordered rollouts.