Reproducing split brain on CloudNativePG
Nikolay SivkoWe run Postgres under an operator for automatic failover. That is a promise about what happens during a failure, so the only way to know you have it is to cause the failure and watch.
The docs tell you what should happen. A config review tells you which knobs are set. Neither tells you how long an isolated primary keeps accepting writes after its replacement has been promoted, and that number decides whether a failover is clean or leaves you with two versions of your data. So we keep chaos testing Postgres operators instead of trusting the datasheet.
This time we tested CloudNativePG, on defaults, with nothing tuned. It should handle this well. Since v1.27 the liveness probe of a primary also asks "can I still reach the API server, and at least one replica?" A primary that cannot say yes fails its own probe and gets killed, and the check is on by default. So we cut the primary off from the API server, the operator and its replicas, and expected a short, boring incident.
Two ways a failover can go wrong
These two often get treated as the same thing. They are not the same failure, and they do not have the same fix.
Data loss at failover is prefix truncation. The standby was at LSN Y, the primary was at X, you promote, and everything between the two is gone. What survives is still a valid history, just a shorter one, and how much went missing is bounded by how far behind the standby was. That makes it something you can plan for: it is what RPO (recovery point objective) measures, the amount of recent data you have agreed you can afford to lose when something fails. Say "we may lose up to five seconds of writes" and you have set an RPO.
Split brain is forked history. Two nodes both accept writes, and each one looks
fine on its own, but put together they could never have come from a single
database: the same sequence values handed out twice, the same primary keys
holding different rows, side effects firing on both branches. You cannot merge
them back. pg_rewind does not even try. It picks a winner and deletes the other
branch.
There is exactly one way to avoid the fork: the old primary has to stop accepting writes before anyone else starts. Which is why the experiment has to record two things rather than one, when each side was writing, and what each client was told while it was.
The setup
A 5-node k3s cluster (v1.34.5+k3s1) running CloudNativePG 1.30.0, PostgreSQL 18.4 and Chaos Mesh 2.7.2.
The database is three instances with pod anti-affinity, one per node, and
nothing tuned. No probes: section, so the isolation check is on. The three
defaults that turn out to matter:
$ kubectl get cluster postgres-splitbrain -o json | jq '.spec | {failoverDelay, smartShutdownTimeout, probes}'
{
"failoverDelay": 0,
"smartShutdownTimeout": 180,
"probes": {
"liveness": {
"isolationCheck": {"connectionTimeout": 1000, "enabled": true, "requestTimeout": 1000}
}
}
}
The clients are three small Go processes, written to behave like a normal
application. Each connects through the postgres-splitbrain-rw Service and then
keeps that connection:
// Established once, then held - deliberately - for the lifetime of the process.
conn := connect(ctx, cfg.clusterDSN, "cluster")
for {
<-ticker.C
post, err := writePost(ctx, conn, cfg.clientID)
if err != nil {
// Only a broken session gets us a new one. During a partition: as long
// as the old primary keeps answering, there is no error, so there is no
// reconnect, so we never learn that a new primary exists.
conn = reconnect(ctx, cfg.clusterDSN)
continue
}
// Postgres said this is durable. Tell the outside world.
ledger.Exec(ctx, `INSERT INTO acked_posts (...) VALUES (...)`, ...)
}
No pool recycling, no max lifetime, nothing that would re-resolve the Service. We did not break it on purpose. This is how a connection pool behaves with default settings, because a Service endpoint change does not tear down a TCP session that is already open.
Each client inserts into a posts table with a bigserial primary key, and
after every commit Postgres acknowledges, records what it was told into a
separate single-instance Postgres called sb-truth. That second database is
how we measure anything at all. It is not part of the cluster under test, so
pg_rewind cannot reach it, and it stands in for everything an application
touches that a database failover cannot undo: the notification it sent, the row
it wrote to another service, the charge it made.
Cutting the primary off
Picture a multi-AZ cluster, one instance per zone, and one zone loses its links to the other two. Inside that zone nothing looks broken: the node is up, and the application pods next to the primary can still reach it. What the primary cannot reach is the API server, the operator and its two replicas.
A single node losing its uplink does the same thing, as does a control plane outage or a NetworkPolicy applied to the wrong selector. All of these are rare. But rare is not never, and the cost here does not scale with the frequency, because nothing alerts and nobody finds out for weeks.
So, three Chaos Mesh NetworkChaos objects, all targeting the primary pod: one
partitions it from the two replicas, one from the operator pods in cnpg-system,
and one from the control plane. The third is the interesting one:
apiVersion: chaos-mesh.org/v1alpha1
kind: NetworkChaos
metadata:
name: sb-partition-external
spec:
action: partition
direction: both
mode: all
selector:
namespaces: [default]
labelSelectors:
cnpg.io/instanceName: postgres-splitbrain-1 # the current primary
externalTargets:
# public addresses below are replaced with the RFC 5737 documentation range
- 10.43.0.1 # kubernetes Service ClusterIP
- 203.0.113.1 # API server endpoint
- 10.42.0.1 # node CNI bridges: where kubelet probes come from
- 10.42.1.1
- 10.42.3.1
- 10.42.4.1
- 10.42.5.1
- 203.0.113.2 # node IPs
- 203.0.113.3
- 203.0.113.4
- 203.0.113.5
The CNI bridge addresses are in there for a reason worth stealing: CNPG refuses to fail over while Kubernetes still reports the pod as Ready, and says so itself.
Warning PrimaryStatusCheckFailed cluster/postgres-splitbrain
Primary pod postgres-splitbrain-1 is Ready but the operator's /pg/status check failed;
failover deferred until Kubernetes marks the primary as not Ready.
Kubelet's probes originate from the bridge, so cutting it is what eventually gets the pod marked NotReady. Note also what is not in any of this: the clients. They are untouched. They stay on the old primary because their TCP session survives, not because we pin them there.
What the events say, and what the ledger says
Partition applied at 18:11:20. Inside the first minute, both mechanisms do exactly what they are supposed to:
18:11:54 Killing Container postgres failed liveness probe, will be restarted
18:11:57 FailingOver Current primary isn't healthy, initiating a failover from postgres-splitbrain-1
18:11:58 FailoverTarget Failing over from postgres-splitbrain-1 to postgres-splitbrain-2
The isolation check fired 34 seconds in and Kubernetes decided to kill the pod. Four seconds later the operator promoted a replacement. Every component behaved as designed, and it was all over inside 40 seconds.
Except it was not, and the ledger shows it. It recorded which server acknowledged every write:
kubectl exec sb-truth-0 -- psql -U truth -d truth -c "
select seen_server, seen_timeline, count(*) writes,
to_char(min(acked_at),'HH24:MI:SS') first_ack,
to_char(max(acked_at),'HH24:MI:SS') last_ack,
min(post_id), max(post_id)
from acked_posts group by 1,2 order by first_ack"
seen_server | writes | first_ack | last_ack | min | max
-------------+--------+-----------+----------+-----+------
10.42.4.248 | 1033 | 18:10:38 | 18:14:55 | 1 | 1033
10.42.0.28 | 562 | 18:13:17 | 18:15:56 | 199 | 760
The isolated primary acknowledged its last write at 18:14:55, which is t+215s and 177 seconds after its replacement was promoted. And read the two rows against each other: the new primary started at 18:13:17 while the old one carried on until 18:14:55. Both were acknowledging commits at once, handing out the same ids. Grouping the same table by second, and keeping only the seconds where both servers appear:
overlap_start | overlap_end | seconds
---------------+-------------+---------
18:13:17 | 18:14:55 | 99
99 seconds in which both primaries were taking writes.
One caveat, because it is easy to overclaim here. A client only finds the new primary if it reconnects, and a client with a working connection never does. So how did one of ours end up there? We restarted a client pod shortly after the partition. That is the most ordinary event in Kubernetes: a deploy, a node drain, an OOM kill, an autoscaler. One restart was enough to put writes on both primaries at the same time.
If nobody had restarted anything, all three clients would have stayed on the old primary until it died, then moved across together. Writes would have gone to one primary at a time instead of both at once. As we are about to see, that barely changes the outcome.
Why the old primary outlived its replacement by 177 seconds
Two numbers have now turned up, and they measure different things.
The 99 seconds is how long our clients actually wrote to both primaries. It depended on when one of them happened to restart, so it is partly luck. Restart a client earlier and it is longer. Restart nothing and it is zero.
The 177 seconds does not depend on luck at all. The new primary was promoted at t+38s and the old one went quiet at t+215s, and for every second in between there were two primaries able to accept writes, whether anybody was using them or not. Any client that reconnects in that window can fork the data. So it is the 177 seconds you should plan around, not the 99.
That gap comes from two separate timers, and nothing makes them agree.
One timer starts the new primary. The readiness probe fails three times, ten
seconds apart, so Kubernetes marks the pod NotReady at around t+34s. Because
failoverDelay is 0, the operator promotes a replacement straight away. Done by
t+38s.
The other timer stops the old one. The liveness probe, which is where the isolation check lives, also fails three times ten seconds apart, and kubelet sends the kill at t+34s. So the fence starts at the same moment. So far this all sounds fine.
The catch is what CNPG does with that kill signal. It runs a smart shutdown, and
a smart shutdown does not disconnect anyone. It refuses new connections, then
waits for the existing sessions to finish on their own, for as long as
smartShutdownTimeout allows. The default is 180 seconds.
Our clients never finish. They hold their connection on purpose, the way a connection pool does, so they get all 180 seconds. The kill landed at 18:11:54 and the last write was acknowledged at 18:14:55.
So failoverDelay and the shutdown budget are not consistent with each other.
The operator promotes at t+38s, while the old primary is guaranteed to keep
serving its existing clients until t+215s. There is no setting for "do not
promote until the old primary is confirmed down", and there could not be, because
the operator cannot reach the old primary to confirm anything about it.
The isolation check is not broken. It starts on time. It just takes six times longer to finish than the promotion it is racing.
What this looks like from the application
Alice writes a post, gets id=123456, and shares /posts/123456. On the
isolated primary that id is her post. On the new primary it is somebody else's.
Same URL, two different objects, both returning 200.
The partition heals, pg_rewind runs, and Alice's branch is the one that loses.
The next day she opens her own link.
It is not a 404. A 404 would be honest, and monitoring would catch it. She gets
200 OK and a stranger's post. The preview card in her friends' chat still shows
her title, cached when she shared it, so the chat is lying too. And if the row
saying "post 123456 is visible to Alice's friends" survived from her branch while
the post came from the other one, this is no longer a data problem. It is a
privacy incident.
And the collisions do not stop when the incident does. The isolated primary gave Alice id 123456, but the surviving branch never saw that post, so its counter is still below 123456. It will hand that id out again, to a brand new post, days later.
How many ids does this affect? Nobody can say. The branch that issued them has been deleted, so there is no record of how far it got. Ten? Ten thousand? Our schema is one table with no foreign keys, which makes the damage easy to describe. A real one has rows referencing rows, ids copied into other services, event streams and warehouses, and links people have already shared. Every one of those references now points at something that may not be what it was written for, and nothing in the database can tell you which ones.
Counting the damage
Nothing in the database can tell you. But the ledger is not in the database.
It sat outside the cluster for the whole experiment, writing down what every
client was told was durable, and pg_rewind never got near it. That is the only
reason this post has numbers in it instead of adjectives.
The check is simple. Take every write the ledger says was acknowledged after the two branches split, look it up in the surviving cluster, and see which of three things happened to it. It is still there and unchanged. It is gone. Or the id is still there, but the row under it now belongs to a different client.
writes acknowledged to clients after the split: 1472
still there, unchanged: 619
GONE - the post the client was promised is absent: 291
STOLEN - that id now belongs to another client: 562
client intact gone stolen
sb-client-7974695569-xqhz6 141 145 281
sb-client-7974695569-rqvld 140 145 281
sb-client-7974695569-7htdt 19 1 0
sb-client-7974695569-w2pls 319 0 0
562 writes that a client was told were durable now point at a row belonging to
somebody else. And look at the last two lines. 7htdt is the pod we restarted,
w2pls is its replacement, and it is the one that landed on the new primary. It
lost nothing at all. The two clients that did nothing but hold their connection
lost or had reassigned three quarters of everything they were promised.
Meanwhile:
$ kubectl get cluster postgres-splitbrain -o custom-columns='READY:.status.readyInstances,PHASE:.status.phase'
READY PHASE
3 Cluster in healthy state
$ kubectl exec postgres-splitbrain-2 -c postgres -- psql -qAt -d sbdemo -c \
"select count(*) from (select id from posts group by id having count(*)>1) x"
0
Three of three ready, no duplicate keys, no constraint violations, not one error in any Postgres log.
The cluster is not lying. pg_rewind did its job properly. It rolled the old
primary all the way back to the point where the two branches split, leaving
nothing half-written behind. What you are looking at is a completely consistent
database. It is just not the one the clients were told about.
That is what makes this so hard to spot. The broken references are not in Postgres at all. They are in the other service that stored an id, the cache, the search index, the email that already went out. And you cannot go back and find them later, because the branch that would tell you which ids were affected has been deleted. We only have the numbers above because something outside the cluster was writing down what each client was told.
Making the two budgets agree
The fix is to stop the two timers working against each other. Both probes default
to failureThreshold: 3 and periodSeconds: 10, so each takes about 30 seconds
to give up. After that, one starts smartShutdownTimeout and the other starts
failoverDelay:
old primary stops = 30s + smartShutdownTimeout = 30 + 180 = 210s
new primary starts = 30s + failoverDelay = 30 + 0 = 30s
Which is the gap we measured. You need failoverDelay larger than
smartShutdownTimeout, and larger by a margin, because those two 30-second
timers are separate and will not fire in the same instant. In our runs the kill
landed 3 seconds before the operator's decision, which happened to help us, but
nothing guarantees it. Twenty or thirty seconds of slack is plenty.
There are two ways to satisfy it. Leave the graceful shutdown alone and raise
failoverDelay past 180, to something like 210. Or make the fence itself fast
with smartShutdownTimeout: 0, and put a smaller failoverDelay on top. The
first keeps graceful drains on healthy switchovers and costs a three-minute
failover. The second gives up the drains and fails over in about a minute. Which
one you want depends on your workload, and we have not tested them against each
other. We tested the second:
spec:
smartShutdownTimeout: 0 # stop waiting for clients to leave, drop them
failoverDelay: 30 # and do not promote until that has certainly happened
Same cluster, same chaos, same clients, partition at 18:18:52:
seen_server | writes | first_ack | last_ack | min | max
-------------+--------+-----------+----------+-----+-----
10.42.0.28 | 307 | 18:18:13 | 18:19:26 | 1 | 307
10.42.4.248 | 350 | 18:20:24 | 18:21:24 | 199 | 548
The old primary went quiet at 18:19:26, the same second kubelet killed it. The new primary took its first write 58 seconds later, and nothing accepted writes in between. That gap is the price of putting the two in the right order.
| old primary stops | overlap | GONE | STOLEN | |
|---|---|---|---|---|
| defaults | t+215s | 99s | 291 | 562 |
smartShutdownTimeout: 0 + failoverDelay: 30 |
t+34s | 0s | 21 | 109 |
Five times less damage. Now look at the second row again: it is not zero.
The old primary spent 34 seconds accepting writes its replicas never received, because replication is asynchronous. Those writes were acknowledged and then thrown away, and the new primary handed their ids out again. None of that needs an overlap. It happens whenever the old primary gets ahead before it is stopped.
So timers only make the window smaller. Closing it altogether means the primary must not acknowledge a write its replicas have not seen, and no timer can promise that. Only synchronous replication can, which is a subject of its own.
What Patroni does differently, and what it does not
It is worth asking how another HA stack handles the same partition, because the difference is instructive in both directions.
Patroni does not infer leadership from pod readiness. It holds a leader lock in a
distributed configuration store (etcd, Consul, ZooKeeper, or the Kubernetes
control plane), and that lock is a lease with a time to live. The ttl parameter
defaults to 30 seconds, with a minimum of 20, and it sets the dead time before an
automatic failover may begin.
The losing side is the interesting half. The primary has to renew its lease every
loop_wait, 10 seconds by default. If it cannot reach the store, which is
exactly our partition, it cannot renew, and as the lease runs out it demotes
itself, without needing to hear from anybody. Only once the lease has actually
expired can another node take the lock and promote.
So the order is guaranteed by the design. The old primary has stopped before the new one can start, because both wait on the same lease rather than on two separate timers that happen to be running near each other. That is the part CloudNativePG leaves you to configure, and by default configures against you.
To be clear about what that buys: it rules out split brain. Two primaries never take writes at the same time, because the lease makes that impossible rather than unlikely. We reached zero overlap on CloudNativePG too, but only by setting two timers correctly, and that is a weaker thing. It held in our runs, and it holds for as long as the arithmetic stays right.
The lease does not protect the writes, though. An isolated Patroni primary still
spends up to ttl seconds accepting writes its replicas never receive, for the
same reason ours did: replication is asynchronous, and nothing is checking. When
it steps down those writes are gone and the new leader hands their ids out again.
About 30 seconds of that, against the 34 we were left with after tuning the
timers.
So stopping the old primary and keeping its writes are two different problems. A lease solves the first outright, which is more than a pair of timers can promise. Neither solves the second without synchronous replication.
Closing
The failure is real, and it is not exotic. A partition like this is rare, but the probability is never zero, and on default settings the window is already there in your cluster.
So go and look at your own setup. Compare failoverDelay with
smartShutdownTimeout, and if an acknowledged write matters to you, consider
synchronous replication.
And this is what chaos testing is for. None of it would have turned up in the docs or in a config review. Breaking a cluster you do not mind breaking is the cheapest way to find out whether the guarantees you think you have are the ones you actually have.
Thanks to Nikolay Samokhvalov for the inspiration and the in-depth details.