Deep Dive

Scaling the gateways: a stateless tier over a fixed consensus core

Why Aeron Cache splits into a fixed, odd-sized RAFT core you never scale for throughput and a stateless gateway tier you scale out freely — and how to lay that out on Kubernetes.

Why it matters

The whole deployment story for Aeron Cache fits in one sentence: you do not scale the consensus core, and you scale everything in front of it. The core is a stateful RAFT cluster — a fixed, odd-sized set of nodes whose job is agreement, not capacity. Adding nodes to it does not buy throughput; it buys fault tolerance, and past a majority it buys nothing. The gateways — HTTP, WebSocket, SSE, Aeron SBE, and the near cache — are a different animal entirely: stateless front-ends that hold no durable state of their own, each an ordinary process you can run as a Kubernetes Deployment and scale to as many replicas as your connection and read load demand.

Get that split wrong — treat the cluster as a horizontal capacity knob, or pin all your clients to a single gateway — and you will fight the architecture. Get it right and scaling becomes almost boring: replicas behind a load balancer, one autoscaler per interface, and a core sized once for the failure budget you actually have.

The split, drawn

Every gateway process is itself an Aeron Cluster client — a cluster-aware client that opens a session to the elected leader and multiplexes many end-client requests and subscriptions over that one cluster session. So a gateway replica is a fan-in/fan-out multiplexer, not a shard of the data. The data lives in the core; the replicas are interchangeable.

                          stateless gateway tier                 fixed consensus core
                      (scale replicas freely, no PVCs)          (sized for fault tolerance)

  clients  ─┐      ┌─────────────────────────────────┐        ┌──────────────────────────┐
   HTTP     │      │  HTTP Deployment    :7070  xN    │        │  Aeron Cluster (RAFT)    │
   WS       ├─ LB ─▶  WS   Deployment    :7071  xM    │──sess──▶  StatefulSet, 3 replicas  │
   SSE      │      │  SSE  Deployment    :7072  xP    │  (to    │  1 leader  2 followers   │
   Aeron    │      │  near cache HTTP    :7070  xQ    │ leader) │  PVC: log + snapshots    │
  ──────────┘      └─────────────────────────────────┘        │  headless Svc / identity │
                                                               └──────────────────────────┘

Clients reach a load balancer; the load balancer spreads them across N gateway replicas; every replica holds a cluster session to the same 3-node core. The core is a StatefulSet because it has identity and durable state — per-node PVCs for the replicated log and snapshots, a headless Service for stable network names so a recovering node rejoins as itself (the mechanics are in RAFT consensus). The gateways are Deployments because they have none of that: kill a replica, the LB routes around it, nothing is lost.

Why the core is fixed at three

A RAFT cluster commits a write once a majority of its members has persisted it, and writes are sequenced by a single elected leader. That design dictates the shape. The member count is odd so a majority is unambiguous — three nodes tolerate the loss of one, which is the standard production default (replicaCount: 3). Going to five raises the failure budget to two lost nodes; it does not raise write throughput, because every write still funnels through one leader and still waits on a quorum. More members mean more nodes to replicate to, not fewer. So the core is sized exactly once, against your availability requirement, and then left alone. This is the point most often missed: the cluster is a consistency and availability mechanism, not a throughput tier. See RAFT consensus for why quorum and determinism make that non-negotiable.

Scale each gateway independently

Because each interface is its own process behind its own enable flag, you deploy one Deployment per interface and give each its own HorizontalPodAutoscaler, scaled on the metric that actually binds it.

  • HTTP REST (:7070, always on). Pure stateless request/response served by Javalin. A connection carries one request and is done, so plain round-robin L7 load balancing is correct and CPU or request-rate is the binding metric. This is the easy case — scale it like any stateless web tier.
  • WebSocket (:7071) and SSE (:7072). These hold long-lived connections. The binding resource is not CPU, it is concurrent connection count, so autoscale on open connections, not load average. A connection, once established, stays pinned to one replica, which makes connection-level load balancing natural. Crucially, any replica can serve any subscription: the cluster fans every change out to every gateway session, so there is no “right” replica for a given cache or key — the LB is free to place a new connection anywhere (see streaming subscriptions).

A disabled gateway is never started, so you tune the fleet per deployment — run a large HTTP + WS tier and turn SSE off, for instance:

# Per-interface Deployment, scaled on its own binding metric.
env:
  - { name: WEBSOCKET_GATEWAY_ENABLED, value: "true"  }
  - { name: SSE_GATEWAY_ENABLED,       value: "false" }   # not started here
  - { name: AERON_GATEWAY_ENABLED,     value: "false" }
---
# HPA for the WebSocket tier binds on connections, not CPU.
kind: HorizontalPodAutoscaler
metric: active_websocket_connections   # the resource that actually saturates

Each new replica you add multiplies the cluster client sessions fanning into the core and the subscription fan-out coming back — which is exactly the load these tiers exist to absorb. The API overview has the full interface table of ports, flags, and specs.

The Aeron SBE gateway is the special case

The native gateway (AERON_GATEWAY_ENABLED, default false) does not play by HTTP’s rules, and its scaling model follows its transport. Over UDP (:7075 / :7076) it uses Aeron’s own channel/endpoint addressing, not L7 HTTP routing, so you cannot drop a stock Ingress in front of it and round-robin — clients address Aeron publications and subscriptions directly. Over IPC the constraint is sharper still: IPC messaging goes through a shared-memory media driver, so client and gateway must share a host and media-driver directory. You cannot load-balance IPC across machines, because there is no network hop to balance. The way you “scale” the IPC path is to co-locate a gateway with its clients — a sidecar alongside the app, or a per-host (DaemonSet-style) gateway that local clients attach to. The transport trade-offs, and why IPC buys you the kernel-bypass path, are covered in Aeron + SBE transport. Keep the mental model: HTTP/WS/SSE scale as a load-balanced tier; the Aeron gateway scales by placement.

The near-cache read tier

Reads have their own horizontal escape hatch. The near cache (cache-near / http-server-near-javalin, spec cache-near/openapi.yml) is a read-ahead HTTP front-end that subscribes to the cluster and keeps a local, full mirror of every key, serving GETs straight from that in-process copy instead of round-tripping to the core. It is itself a horizontally-scalable read tier: add replicas to multiply read capacity, with the cost being memory — each replica holds the whole dataset, so you trade RAM per replica for read throughput. Its low-GC design keeps that per-replica footprint predictable, which is what makes stamping out many mirrors sane. The alternative, when the reader is your own application, is the client-side embedded mirror — the same read-ahead copy living inside your app process, so there is no tier to operate at all (see embedded clients and streaming subscriptions). Choose a near-cache tier when many thin clients need fast reads; choose the embedded mirror when the reader can host the mirror itself.

Writes still funnel to the leader

Be honest about the ceiling, because it is a feature, not a bug. Scaling the gateway tier scales fan-in of connections and fan-out of reads and subscriptions — the number of clients you can hold and the number of streams you can push. It does not scale write throughput. Every mutation is sequenced by the single RAFT leader and committed by quorum, so write capacity is bounded by the core, and no number of gateway replicas changes that. That bound is precisely what buys you linearizable, consistent state across a failover: there is exactly one place writes are ordered. If writes are your bottleneck, the answer is not more gateways — it is batching (the bulk-ops surface) and right-sizing the core’s hardware, not widening it.

Operational hygiene on Kubernetes

A few practices keep the split healthy under real cluster churn:

  • Readiness probes that mean it. A gateway replica should report ready only once its cluster session is connected — otherwise the LB routes clients to a replica that cannot reach the leader. Gate readiness on the session, not just on the HTTP port being open.
  • PodDisruptionBudget on the core. Put a PDB on the StatefulSet so a node drain or rolling upgrade can take at most one of three nodes down at a time. Lose two and you lose quorum, and the cluster stops accepting writes until a majority returns.
  • L4/L7 LB and Ingress for the stateless tiers. Front HTTP/WS/SSE with an Ingress or L4 load balancer; keep WS/SSE on connection-aware balancing so a long-lived connection is not torn mid-stream.
  • Graceful connection draining. On a gateway rollout, drain the streaming tiers — stop accepting new connections, let clients re-subscribe elsewhere — so a deploy does not drop every live subscription at once. Because any replica can serve any subscription, a reconnecting client simply lands on a healthy replica and rehydrates.
# Build the images, then install the clustered deployment from the Helm charts.
make all
cd k8s/helm && make install-all

See Getting Started for the local monolith — the single process that co-locates core and gateways — then separate and scale the tiers when you move to the cluster.

Takeaway: Aeron Cache scales by division of labor — a fixed, odd-sized, quorum-bound core that owns consistency and write ordering, and a stateless gateway tier you grow freely to absorb connections and reads. Size the core once for your failure budget, put each interface behind its own autoscaler on the metric that binds it, co-locate the IPC path, and let the near cache carry the read fan-out. The leader stays the single source of truth; everything else is just replicas.