Game infrastructure

Autoscaling game servers on connections, not CPU

A session server at 30% CPU can be completely full. What to scale on instead, why a reactive autoscaler is always late, and how to drain a pod that is holding a live race.

Short answer. Scale on active connections per pod, not CPU. A session server is bound by the connections and the state it owns, so it fills long before its cores do — and CPU gets the scale-down decision actively wrong, because a pod holding a live match can look idle. We run KEDA against a per-pod connection gauge, place sessions with our own orchestrator because placement is coupled to matchmaking, and drain with a preStop hook so that a patch during a live event is routine rather than an incident. The one thing no autoscaler fixes is that pods start slower than events fill, which is a headroom decision rather than a configuration one.

Why CPU is the wrong signal for a game server

CPU utilisation is the default autoscaling metric because for stateless request handlers it is a good proxy: work arrives, work is done, cores get busy, and any replica can serve any request. A game session server breaks all three assumptions. It holds long-lived connections, it owns state that cannot be moved mid-match, and its tick loop does roughly constant work per session whether the session is exciting or idle.

That produces two failures, and they pull in opposite directions.

Scaling up on CPU is late. CPU is an average over a window; a lobby filling is an event. By the time a rolling average crosses a threshold, the queue has already been waiting, and the players who felt it have already formed an opinion about your matchmaking. The signal you want moves the instant a connection is accepted.

Scaling down on CPU is worse than late; it is wrong. Between matches a pod's CPU falls. A CPU-driven autoscaler reads that as spare capacity and terminates the pod — and if the pod's next match has already started, that is a race ended by an infrastructure decision. This is the failure that turns into a review saying the game disconnects you at random, and it is entirely self-inflicted.

The interesting part is that both failures have the same cause: CPU describes how busy the machine is, and the question the autoscaler is actually asking is how full the tier is and whether this particular pod is safe to remove. Those are different questions, and one metric answers both of them.

Scaling on active connections per pod, with KEDA

Each session pod exports one gauge: the number of live connections (or live sessions — use whichever is the unit your capacity is measured in) that it currently owns. The autoscaler targets an average value of that gauge per pod, not a total.

The per-pod framing matters more than it looks. Capacity planning for a game server is already per-pod: load testing tells you a pod holds N sessions before latency degrades. Targeting an average per pod makes the autoscaler's arithmetic the same arithmetic as the capacity model, so the number in the manifest is a number someone measured rather than a number someone tuned.

# KEDA reads the gauge directly; a plain HPA would need an adapter
# exposing the same value through the custom-metrics API, which is
# one more component to own.
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: session-tier
spec:
  scaleTargetRef:
    name: session-tier
  minReplicaCount: 6        # a floor, not an optimisation
  cooldownPeriod: 300       # seconds of calm before scaling in
  triggers:
    - type: prometheus
      metadata:
        query: sum(game_session_connections)
        threshold: '70'   # target per pod: 70% of the tested ceiling
The trigger is a Prometheus one because that is usually where a per-pod gauge already lives; KEDA has around seventy scalers and the choice does not change the argument. What matters is the threshold: it is 70% of the ceiling load testing found, and the missing 30% is not waste, it is the time pods need to start.

Three details that are easy to get wrong:

Why a reactive autoscaler is always late, and what to do about it

Connections per pod is a current load signal. It tells you the tier is filling; it cannot tell you the tier is about to fill. And the gap between those two facts is filled by something no metric changes: how long a pod takes to become useful.

Add it up honestly — image pull, process start, readiness probe passing, and any warm-up your server does before it can accept a session. Call it T. If concurrency can double in less than T, a purely reactive autoscaler cannot keep up, and no threshold tuning will change that. There are only two real levers:

This is also the honest answer to "why not scale on a leading indicator like queue arrival rate". You can, and it helps at the margin. But a derivative is noisy, and a noisy signal driving scale-in is how you get flapping on a tier where flapping ends matches. We keep the load signal simple and buy the reaction time with headroom instead.

Why we run our own orchestrator instead of Agones

Agones is good, it is the default answer to this problem, and most studios should use it. It gives you game-server lifecycle as a Kubernetes primitive: allocation, fleet management, health, and a well-trodden upgrade path.

We run our own placement for one reason: session placement is coupled to matchmaking. By the time a session needs a home, the matchmaker has already solved constraints — party, region, latency band, rating — and placement has to honour the solution it just produced rather than re-derive it. Putting an allocation API between the matchmaker and the pod means either duplicating those constraints or flattening them, and both cost you the thing the matchmaker exists for.

The trade is real and worth stating plainly: you own the failure modes, the upgrade path and the pager. If you are not already writing a matchmaker with non-trivial constraints, this is not a trade worth making. Use Agones and spend the saved months on the game.

Draining game servers with preStop, so a patch during an event is routine

Autoscaling and deployment are the same problem wearing different hats: both remove pods, and a pod holding a live race must not simply die. This is where most game backends discover their scaling design is incomplete, usually during an event.

What has to be true, in order:

  1. The pod stops being a placement target the moment it is marked for termination — before anything else happens. With our own orchestrator that is an explicit signal to the placer; with an allocation API it is the allocation being withdrawn. If new sessions can still land on a draining pod, nothing below matters.
  2. Existing sessions finish. Not migrated, not force-ended: finished. A race is short, which is exactly what makes this tractable for a runner and hard for a persistent world.
  3. The process exits only then, and the grace period has to be long enough to allow it.
# preStop runs before SIGTERM reaches the process, and the whole
# sequence is bounded by terminationGracePeriodSeconds. If that
# number is smaller than your longest session, Kubernetes ends
# matches for you.
lifecycle:
  preStop:
    exec:
      command: ["/bin/drain", "--stop-accepting", "--wait-for-empty"]
terminationGracePeriodSeconds: 600   # longest match + margin
readinessProbe:                       # false while draining
  httpGet: { path: /ready, port: 8080 }
livenessProbe:                        # still true, or Kubernetes
  httpGet: { path: /healthz, port: 8080 }  # restarts the pod
                                        # you are trying to retire
The two mistakes this prevents are a grace period shorter than a match, and a liveness probe that fails during drain — which restarts the very pod you are gracefully removing, killing the sessions you were waiting for.

The reward for getting this right is specific and worth the effort: deploying during a live event stops being a decision. Pods retire as their matches end, new pods take new sessions, and no player sees anything. Without it, every patch waits for a quiet window, and a live game does not have many.

What this looked like at 39,000 concurrent players

The stack above is what runs behind Metarun, a 1v1v1 competitive mobile runner we built end to end: KEDA on a per-pod connection gauge, our own placement coupled to MMR matchmaking, preStop draining. Its measured peak was 39,000 concurrent players, reached during an in-game event, at 60 ms average round-trip to the match service and 99.8% uptime in production. Three players per race at that concurrency is on the order of thirteen thousand simultaneous authoritative sessions, each one a pod's worth of state that cannot be moved.

A separate title, which we cannot name, ran the same design at 52,000 peak concurrent players with 62 ms average latency and 99.98% uptime. Different game, different numbers, same three decisions — and in both cases the event peaks were the ones that mattered, because an average never breaks anything.

What this does not cover

Deliberately out of scope, because each is its own decision: multi-region placement and cross-region matchmaking; spot or preemptible capacity, which interacts badly with long-lived sessions unless draining is already solid; session migration, which is the answer for persistent worlds and mostly unnecessary for match-based ones; and the cost of all of it, which has its own arithmetic where egress rather than compute turns out to be the largest line.

For what the tier this autoscales actually looks like, and what breaks first on the way up, see backend architecture for 50,000 concurrent players.

Common questions

Three that come up once the metric is chosen and the cluster has to run it.

Because a session server is bound by the connections and the state it owns, not by compute, so it can be full at 30 per cent CPU. Two consequences follow. Scaling up on CPU is late, since CPU is an average over a window while a match filling is an event. Scaling down on CPU is dangerous, because a pod holding a live race can look idle to a CPU metric, and terminating it ends the race. Active connections per pod carries both signals correctly: it rises immediately when the tier fills, and it is non-zero for exactly as long as the pod must not be killed.
We use KEDA. A plain HPA can scale on a custom metric, but it needs an adapter to expose that metric through the Kubernetes metrics API, and the adapter becomes a component you own and operate. KEDA already speaks to the metric source directly, has around seventy scalers, and can hold a minimum replica floor and a cooldown window without extra machinery. Neither choice changes the argument about which signal to scale on, which is the part that matters.
Agones is the right default and most studios should use it. We run our own placement because session lifecycle in these titles is coupled to matchmaking: the matchmaker already knows the party, the region and the rating band, and placement has to honour constraints it has just solved. The honest trade is that you then own the failure modes, the upgrade path and the on-call for it. If you are not already writing a matchmaker with non-trivial constraints, that trade is not worth making.

Scaling a session tier that has to hold up?

We will review your signal, your headroom and your drain path, and tell you which of the three is currently the reason your deploys wait for a quiet window.

Message received

We’ll review your enquiry and respond within one business day.

Related reading