Skip to content

CP-47394: Shard Alloy work across replicas - #1005

Merged
evan-cz merged 2 commits into
developfrom
CP-47394-alloy-clustering
Sep 5, 2026
Merged

CP-47394: Shard Alloy work across replicas#1005
evan-cz merged 2 commits into
developfrom
CP-47394-alloy-clustering

Conversation

@evan-cz

@evan-cz evan-cz commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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 HPA walks to maxReplicas and 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.enabled and --cluster.name were guarded on the agent mode being federated, inside a block already guarded on the mode being clustered. 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:67 with neither join nor discover peers set, returns no discovery function
discovery/distributed_targets.go:64-68 belongsToLocal = … || peers[0].Self — one-member ring resolves every key to self
kubestate/sharding.go:50 shouldProcessUID routes into the same path, so the plugin inherits it

The 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.go passes ListenAddress: fr.httpListenAddr into the cluster service), so it must track --server.http.listen-addr.

Fail-safe by design

--cluster.wait-for-size is deliberately not set. cluster_readonly.go:134 returns Ready() 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.name is 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 from cloudzero-agent.server.fullname instead, 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 -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.

Verified across every shape, including a base already ending in the fallback suffix:

63 'a'                  ok
55 'a' + -cluster (63)  ok
54 'a' + -cluster (62)  ok
40 'a'                  ok
57 'a' + -peers   (63)  ok

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):

BEFORE   pods re-sharded  total=73  local=73    (one replica owns everything)
AFTER    pods re-sharded  total=75  local=35    peers_count=4

That deployment also surfaced the empty --cluster.name defect, 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

@evan-cz
evan-cz requested a review from a team as a code owner September 4, 2026 18:41
@greptile-apps

greptile-apps Bot commented Sep 4, 2026

Copy link
Copy Markdown

Greptile Summary

The PR enables Alloy clustering for clustered-mode deployments and adds a headless Service for peer discovery.

  • Derives a release-scoped ring identity and collision-safe discovery Service name.
  • Adds Helm coverage for clustering flags, discovery configuration, and naming boundaries.
  • Extends the KUTTL suite to deploy two replicas and verify peer membership and divided ownership at runtime.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
helm/templates/_helpers.tpl Adds a bounded, collision-safe helper for the Alloy peer-discovery Service name.
helm/templates/agent-cluster-service.yaml Adds a clustered-mode headless Service that publishes all replica addresses before readiness.
helm/templates/agent-deploy.yaml Enables Alloy clustering and configures release-scoped identity and DNS peer discovery.
helm/tests/alloy_cluster_service_test.yaml Covers Service rendering, discovery properties, selectors, ports, and prior naming-collision boundaries.
tests/kuttl/alloy-clustered-test/steps/03-verify-clustering.yaml Adds runtime assertions for a multi-member ring and strict-subset target ownership.

Reviews (15): Last reviewed commit: "CP-47394: Shard Alloy work across replic..." | Re-trigger Greptile

Comment thread helm/templates/_helpers.tpl Outdated
Comment thread helm/tests/alloy_deployment_test.yaml
@evan-cz
evan-cz force-pushed the CP-47394-alloy-clustering branch 2 times, most recently from d67edc4 to c2c5b75 Compare September 4, 2026 19:10
Comment thread helm/templates/_helpers.tpl Outdated
Comment thread helm/templates/_helpers.tpl
@evan-cz
evan-cz force-pushed the CP-47394-alloy-clustering branch from 7677af2 to ed167c4 Compare September 4, 2026 20:17
@evan-cz
evan-cz changed the base branch from develop to CP-47363-kuttl-diag September 4, 2026 20:17
Comment thread helm/templates/_helpers.tpl Outdated
@evan-cz
evan-cz force-pushed the CP-47394-alloy-clustering branch from ed167c4 to 5d8a352 Compare September 4, 2026 20:53
Comment thread helm/templates/_helpers.tpl
@evan-cz
evan-cz force-pushed the CP-47394-alloy-clustering branch from 5d8a352 to 5f6b2ba Compare September 4, 2026 21:12
Comment thread helm/templates/_helpers.tpl Outdated
@evan-cz
evan-cz force-pushed the CP-47394-alloy-clustering branch from 5f6b2ba to 05cab16 Compare September 4, 2026 21:30
@evan-cz

evan-cz commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

Right again, and this exposed that the approach itself was wrong rather than just the constant.

Reproduced the 62-character case: server.fullnameOverride of 54 'a' plus -cluster. Trimming to 55 cuts the separator, trimSuffix removes the trailing dash, and appending rebuilds the base — and the fallback at 54 does exactly the same thing. Shortening can never fix this, because the reconstruction just recurs at another length.

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 -cluster, and the fallback ends in -peers, so they cannot be equal for any input.

Verified across every shape, including a base that already ends in the fallback suffix:

base 63 'a'                -> ok
base 55 'a' + -cluster (63) -> ok
base 54 'a' + -cluster (62) -> ok
base 40 'a'                -> ok
base 57 'a' + -peers (63)  -> ok

Both boundary cases are pinned by tests; 679/679 passing.

@greptileai review

@evan-cz
evan-cz changed the base branch from CP-47363-kuttl-diag to CP-47350-alloy-repin September 4, 2026 21:45
@evan-cz
evan-cz force-pushed the CP-47394-alloy-clustering branch from 05cab16 to e7f543b Compare September 4, 2026 21:59
@evan-cz
evan-cz changed the base branch from CP-47350-alloy-repin to CP-47363-kuttl-diag September 4, 2026 21:59
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>
@evan-cz
evan-cz force-pushed the CP-47394-alloy-clustering branch 4 times, most recently from fdab23e to e51efd6 Compare September 4, 2026 23:08
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>
@evan-cz
evan-cz force-pushed the CP-47394-alloy-clustering branch from e51efd6 to ca9b1db Compare September 4, 2026 23:25
@evan-cz
evan-cz added this pull request to the merge queue Sep 4, 2026
Base automatically changed from CP-47363-kuttl-diag to develop September 5, 2026 00:23
Merged via the queue into develop with commit ca6ae1a Sep 5, 2026
46 checks passed
@evan-cz
evan-cz deleted the CP-47394-alloy-clustering branch September 5, 2026 00:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants