The worst production failures are not the loud ones. A crash loop pages you. A failed sync shows up red in the UI.
What we hit last weekend did neither: Argo CD kept reporting Synced and Healthy for 313 Applications while
absolutely nothing was being deployed to them — for 57 hours, across two clusters, with no error, no Application
condition, and a CI pipeline that reported success the whole time.
The trigger was about as ordinary as it gets. Somebody registered a new cluster.
The symptom
A developer asked why their change wasn’t live. The pipeline was green. argocd app wait --health --sync had
returned success three hours earlier. The Application’s status said exactly what you’d want it to say:
| |
That reconciledAt is the tell. Argo CD had not looked at this Application since a specific moment two days
earlier — it had simply stopped, and kept publishing its last opinion as if it were current.
This is worse than an outage. An outage tells you it is happening. This told our automation everything was fine.
Finding it
Our setup: application-controller as a Deployment with 10 replicas, dynamicClusterDistribution: true,
controller.sharding.algorithm: round-robin, 48 clusters, roughly 3,300 Applications. Argo CD v3.4.5,
Helm chart 9.7.1.
Four signals located the problem within about an hour:
1. The clusters looked half-registered. argocd cluster list showed the two affected clusters with an empty
connection state, applicationsCount: 0, and no cacheInfo.lastCacheSyncTime — while all 46 others were
Successful with a fresh cache.
2. They appeared in nobody’s logs. We grepped roughly 41,000 log lines over a 5-minute window across all ten controller pods. A cluster with 304 Applications should be mentioned constantly. It appeared zero times. Not an error about it — nothing.
3. One replica was shouting into the void. A single pod logged, about 2.8 times per second:
| |
4. Ownership didn’t add up. Extracting dest-name= from each pod’s logs and comparing against
argocd-app-controller-shard-cm showed two clusters being processed by two replicas simultaneously, and our two
orphans processed by none.
Meanwhile every shard entry in the ConfigMap was heartbeating normally, and no pod had restarted — they were four to five days old. Nothing had crashed. The distribution had simply drifted apart in memory.
Why adding one cluster moves half the fleet
Here is the part that surprised me, and the reason a single cluster registration is not a small event.
Round-robin assigns a shard by position:
| |
The sort key is Cluster.ID. And in util/db/cluster.go, SecretToCluster sets:
| |
s.UID is the cluster Secret’s Kubernetes UID — a random UUID assigned at creation time. So the “sorted cluster
list” is sorted by random identifiers, and a newly created cluster lands at an arbitrary position in it. Every
cluster sorting after that position shifts by one index, and therefore changes shard.
In our case the fleet went from 47 to 48 clusters, and the new Secret’s UID placed it at index 21 of 48. That re-indexed 26 of the 47 existing clusters. Adding one cluster reassigned more than half the estate.
That alone is not a bug — the controllers are supposed to converge on the new distribution. What actually happened is that one replica applied the re-shuffle partially. Reconstructing the intended distribution from the Secret UIDs (a model that matched the post-restart ownership we observed for 14 of 14 spot-checked clusters), one shard should have changed like this:
| members of that shard | |
|---|---|
| before the add | in-cluster, stg-1, stg-2, stg-3, stg-4 |
| after the add | in-cluster, stg-4, dev-1, cluster-A, cluster-B |
(Cluster names are anonymized; the labels carry no ordering — the real sort key is a random UID.)
What the replica owning that shard was actually processing 57 hours later:
in-cluster,stg-4— unchanged, finedev-1— moved in, correctly picked upstg-1,stg-3— moved away, still being processed here and by their new ownercluster-A,cluster-B— moved in, never picked up by anyone
So it wasn’t a wholesale stale map. It was per-cluster and partial: one of three incoming clusters adopted, two dropped on the floor, two outgoing clusters never released.
The recovery, and why it isn’t a fix
| |
Both clusters reconnected within 60 seconds. All 313 Applications reconciled within five minutes. The log spam stopped. Nothing else — credentials, network, RBAC, the shard ConfigMap — was touched.
That’s a satisfying fix for about ten minutes, until you realise what it means: the only thing that reliably
rebuilds the distribution is process start. Init() — the one full re-derivation from an authoritative cluster
list — runs exactly once per process. After that, the in-memory map is maintained purely by incremental events. If a
replica ever misses one, nothing repairs it and nothing notices.
Upstream: what I found in the code
I filed issue #29476 with the reproduction and the evidence, then
kept reading. In controller/sharding/cache.go, Update looked like this:
| |
hasShardingUpdates returns false when ID, Server and Shard are all unchanged — which is exactly the
old == new pair the cluster-secret informer delivers on its periodic resync. So if a cluster’s first
appearance on a given replica arrives as an update rather than an add, it gets inserted into Clusters with no
entry in Shards — and nothing ever repairs that.
Add has always guarded for this case with !ok ||. Update did not.
Two consequences, and the second one is nastier:
- For index-based algorithms like round-robin, a replica whose
Clustersmap differs from its peers’ by a single entry computes a different shard for every cluster sorting after that difference. One missed event desynchronises a whole tail of the fleet. - A cluster missing from
Shardsisn’t merely unowned.IsManagedClusterfalls back toclusterShard := 0, so it gets claimed by whichever replica happens to be shard 0 and rejected by every other replica — including its rightful owner.
The fix
PR #29477 gives Update the same guard Add already had:
| |
Three lines. It restores an invariant every other mutator in that file already maintains: every cluster in
Clusters has an entry in Shards.
The test runs two replicas over the same three clusters, where one replica sees an Add for the third cluster and
the other sees only an Update. Without the fix it fails with both halves of the production symptom at once:
| |
One cluster processed twice, one processed by nobody — the same two failure modes we saw in production, reproduced in a unit test.
The PR is open at the time of writing, and deliberately does not close the issue. What I can prove is that this is one reachable way for a replica’s map to diverge, and that it’s a local invariant fix worth making on its own. What I can’t prove is that it was the specific path our incident took: the reconstruction came from a 120-second sample of each replica’s logs, and controller logs at that volume retain about seven minutes, so it can’t be re-verified after the fact. There are at least two other reachable divergence paths in the same area. Saying “this fixes it” would have been a nicer story and a worse bug report.
What I’d tell you to do about it
If you run Argo CD with more than one application-controller replica, three things are worth doing today:
Alert on stale reconciliation, not just on sync status. reconciledAt drifting far past your
timeout.reconciliation is the only signal that distinguishes “genuinely healthy” from “frozen and lying”. Nothing
in the health or sync status will tell you.
Treat has no assigned shard as a page, not a warning. In a correctly distributed fleet that line should never
appear. Ours was emitting it 2.8 times a second for two and a half days and nobody was looking.
Reconcile ownership against the shard ConfigMap. Periodically compare which clusters each replica is actually
processing against argocd-app-controller-shard-cm. Any cluster with zero owners — or two — is the failure, before
it becomes an incident.
And the uncomfortable one: your CI’s argocd app wait --health --sync is not proof of deployment. It is proof
that Argo CD’s last recorded opinion was healthy. If you need proof that the new image is running, check the running
image.
Argo CD v3.4.5, Helm chart 9.7.1. The relevant file controller/sharding/cache.go is byte-identical at v3.4.4,
v3.4.8 and v3.5.2, so this applies across the 3.4 and 3.5 lines. Issue:
#29476 · PR:
#29477.