CP-47394: Shard Alloy work across replicas - #1005
Conversation
Greptile SummaryThe PR enables Alloy clustering for clustered-mode deployments and adds a headless Service for peer discovery.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains. Important Files Changed
Reviews (15): Last reviewed commit: "CP-47394: Shard Alloy work across replic..." | Re-trigger Greptile |
d67edc4 to
c2c5b75
Compare
7677af2 to
ed167c4
Compare
ed167c4 to
5d8a352
Compare
5d8a352 to
5f6b2ba
Compare
5f6b2ba to
05cab16
Compare
|
Right again, and this exposed that the approach itself was wrong rather than just the constant. Reproduced the 62-character case: The fallback now uses a different suffix instead of a shorter one. That is distinct by construction: the branch is only reached when the base ends in Verified across every shape, including a base that already ends in the fallback suffix: Both boundary cases are pinned by tests; 679/679 passing. @greptileai review |
05cab16 to
e7f543b
Compare
The clustered suite fails with a bare "context deadline exceeded", and kuttl deletes the namespace immediately afterwards, so nothing survives to say which container never became ready. That is why this suite went undiagnosed for months: the evidence is discarded before it can be read. Dump pod phases, container states, events, and Alloy logs when the install does not become ready. That dump is what identified the actual cause -- the Alloy container crash-looping on conflicting OpenTelemetry schema URLs, fixed separately in the Alloy fork -- rather than the timeout it presented as. Also shrink the release. The suite installs a second full-size stack beside the default one already on the node, and clustered mode fans the server out to three replicas. That is not what was failing here, since the suite passes without this change once Alloy starts, but the same overlay already exists for the webhookServer suites (CP-43292) and leaves headroom rather than relying on there being enough. The wait goes from three minutes to eight for the same reason as the dump: at three, a slow start and a broken install look identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fdab23e to
e51efd6
Compare
Alloy replicas each collect the entire cluster and remote_write all of
it, so adding replicas does not reduce per-replica load. On a large
cluster the horizontal pod autoscaler walks to maxReplicas and stays
there, while the aggregator receives one copy of the cluster's metrics
per replica.
Every pipeline in the River config declares `clustering { enabled =
true }`, but those blocks do nothing unless the process is also started
clustered, and replicas can only divide work if they can find each
other. Neither held. The --cluster.enabled and --cluster.name flags were
guarded on the agent mode being "federated", inside a block already
guarded on the mode being "clustered"; the two are mutually exclusive,
so the flags had never been rendered in any configuration. There was no
headless service either, so peer discovery had nothing to resolve.
Each replica therefore formed a single-member ring and owned every
target.
Fix the guard and add the headless service that peer discovery needs.
Alloy gossips over its HTTP listen port rather than a dedicated one, so
the service exposes 9090 to match --server.http.listen-addr, and it sets
publishNotReadyAddresses so a full restart cannot deadlock waiting for
pods that are not discoverable until they are ready.
The ring identity comes from the server resource name rather than
.Values.clusterName. That value identifies the Kubernetes cluster to
CloudZero for cost attribution, is optional, and defaults to empty, so
keying the ring on it would label most installations with nothing.
The headless service name cannot collide with the regular server
Service. They are different contracts, so sharing a name means a
duplicate resource on install or an upgrade asking Kubernetes to convert
an existing ClusterIP Service to a headless one, which it rejects.
Reserving room for the suffix is not enough, because appending to a
truncated base can rebuild the base: both a 63-character and a
62-character name ending in "-cluster" reconstruct themselves, and
shortening further just moves the problem to another length. So when the
derived name matches, the helper falls back to a different suffix, which
is distinct by construction -- that branch is only reached when the base
ends in "-cluster", and the fallback ends in "-peers".
Deliberately does not set --cluster.wait-for-size: leaving it unset
keeps cluster.Ready() unconditionally true, so replicas that cannot
reach their peers fall back to a single-member ring and process
everything. That degrades to the current behaviour rather than to
emitting nothing.
That fail-safe also hides its own failure, so readiness and rendered
output cannot detect broken discovery. The clustered kuttl suite now
installs two replicas and asserts from the Alloy logs that the ring has
more than one member and that a replica owns a strict subset of the
cluster's pods.
This does not reduce the per-replica baseline. Every replica still runs
informers that receive the full object stream, so the memory floor still
scales with cluster size; what now scales horizontally is the processing
and emission above that floor.
Verified on a live three-replica cluster of the same topology: before
the change a replica logged total=73 local=73 with no peer discovery;
after it, peers_count reached four and a replica logged total=75
local=35. That deployment also surfaced the empty --cluster.name defect,
which no amount of chart testing would have caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e51efd6 to
ca9b1db
Compare
Alloy replicas each collect the entire cluster and
remote_writeall of it, so adding replicas does not reduce per-replica load. On a large cluster the HPA walks tomaxReplicasand stays there, while the aggregator receives one copy of the cluster's metrics per replica.Why clustering was inert
Every pipeline in the River config declares
clustering { enabled = true }— but those blocks do nothing unless the process is also started clustered, and replicas can only divide work if they can find each other. Neither held:1. The flags could never render. In
agent-deploy.yaml,--cluster.enabledand--cluster.namewere guarded on the agent mode beingfederated, inside a block already guarded on the mode beingclustered. Mutually exclusive — so they have never been rendered in any configuration since they were introduced.2. There was no headless service, so peer discovery had nothing to resolve.
Each replica therefore formed a single-member consistent-hash ring and owned 100% of targets.
Confirmed against Alloy source rather than inferred:
discovery/peer_discovery.go:67discovery/distributed_targets.go:64-68belongsToLocal = … || peers[0].Self— one-member ring resolves every key to selfkubestate/sharding.go:50shouldProcessUIDroutes into the same path, so the plugin inherits itThe fix
Correct the guard, and add the headless service peer discovery needs. Two properties matter and are not interchangeable with the existing server service:
clusterIP: None— a normal ClusterIP load-balances to one backend, so a replica would discover at most one arbitrary peer. Headless DNS returns every pod IP.publishNotReadyAddresses: true— otherwise a full restart deadlocks: no pod joins the service until ready, and the ring cannot form from pods that cannot see each other.Port 9090 because Alloy gossips over its HTTP listen port (
cmd_run.gopassesListenAddress: fr.httpListenAddrinto the cluster service), so it must track--server.http.listen-addr.Fail-safe by design
--cluster.wait-for-sizeis deliberately not set.cluster_readonly.go:134returnsReady()unconditionally true unless that flag is also set, so replicas that cannot reach peers fall back to a single-member ring and process everything — degrading to today's behaviour, never to emitting nothing. Setting it would mean a failed ring produces no metrics at all.What this does not fix
The per-replica baseline. Every replica still runs informers receiving the full object stream, so the memory floor still scales with cluster size. What now scales horizontally is the processing and emission above that floor — large clusters will still need a higher memory request, but the autoscaler should converge instead of saturating.
Ring identity comes from the resource name
--cluster.nameis a gossip label: a node refuses to join peers whose label differs. It is not taken from.Values.clusterName— that value identifies the Kubernetes cluster to CloudZero for cost attribution, is optional, and defaults to empty, so keying the ring on it would label most installations with nothing. It is derived fromcloudzero-agent.server.fullnameinstead, which is release-scoped and always present.Service name cannot collide with the server Service
The headless Service and the regular server Service are different contracts, so sharing a name means a duplicate resource on install, or an upgrade asking Kubernetes to convert an existing ClusterIP Service to a headless one — rejected as an immutable field change.
Reserving room for the suffix is not sufficient, because appending to a truncated base can rebuild the base. Both a 63-character and a 62-character name ending in
-clusterreconstruct themselves, and shortening further just moves the problem to another length. So when the derived name matches, the helper falls back to a different suffix, which is distinct by construction: that branch is only reached when the base ends in-cluster, and the fallback ends in-peers.Verified across every shape, including a base already ending in the fallback suffix:
Verified on a live cluster
Chart tests prove the flags render; only a cluster proves they work. On a 3-replica AKS install of the same topology (
mode: clustered,webhookServer: false, kubeState on):That deployment also surfaced the empty
--cluster.namedefect, which no amount of chart testing would have caught.Testing
679/679 helm unit tests,
helm lint, schema + kubeconform. The rendered chart was checked to confirm the service selector actually matches the deployment's pod labels — a silent mismatch there would look fine and fix nothing.The existing unit test asserted the flags were absent, encoding the bug as intended behaviour. It now asserts the opposite, and the new service has its own coverage.
🤖 Generated with Claude Code