Skip to content
Brian Feeny
Go back

Sizing Aurora Connections for Autoscaled Services on AWS

A service that autoscales on request rate scales its connection count along with it. The database’s connection ceiling does not move. The two meet at a point nobody chose, and the symptom rarely looks like a connection problem.

The failure has a recognizable shape. Traffic climbs, target tracking adds tasks, and latency gets worse. Someone adds more tasks, because that is what the runbook says to do, and it gets worse again. The database CPU is unremarkable. Query latency is unremarkable. What has actually happened is that the fleet has opened more connections than the instance will accept, and new tasks are now failing to connect while the ones that already hold connections wait behind them.

The reason this is easy to miss is that no single number involved looks wrong. Thirty tasks is not a lot. Four workers per task is correct. A pool limit of ten is conservative. It is the product that is wrong, and nothing in the system reports the product.

The ceiling is made of memory, not of traffic

On Aurora PostgreSQL, max_connections is not a round number somebody picked. The default parameter value is a formula:

LEAST({DBInstanceClassMemory/9531392},5000)

Two things follow from that, and both matter more than the formula itself.

One connection costs roughly 9.1 MiB of instance memory. That is the divisor. PostgreSQL forks a backend process per connection, and AWS sizes the default ceiling so those processes cannot exhaust the instance. So the ceiling scales with the instance class — a 16 GiB instance lands somewhere near 1,800 connections. Near, not exactly: DBInstanceClassMemory is the memory available to the engine, which is a little below nominal RAM. Run SHOW max_connections; against your own cluster rather than trusting my arithmetic.

The ceiling stops at 5,000, whatever you buy. Past roughly 45 GiB of instance memory the LEAST clause binds and the formula stops mattering. This is the part that surprises people midway through an incident: scaling the writer up a class is a real lever until it abruptly isn’t, and there is no instance class that gets you to 8,000 connections.

Three of those slots are not yours either. superuser_reserved_connections defaults to 3 and is not modifiable, which is what keeps a saturated cluster reachable at all.

The floor is made of multiplication

Now the other side. A containerized Python service typically runs one worker process per vCPU, and connection pools are held per worker process, not per task. So the connection count is a product of three independent numbers:

Figure 1.

Each term is individually reasonable; only the product exceeds the ceiling. Of the three, autoscaling owns the first, the instance size owns the second, and you own the third.

Two of those terms are not under your control at the moment it matters. Task count is set by the scaling policy, which is to say by your users. Workers per task is set by the CPU allocation. The pool limit is the only term you get to choose, and it is therefore the term that absorbs every mistake in the other two.

A detail worth checking before you trust your own numbers: don’t let the worker count come from $(nproc) inside a container. Under Kubernetes CPU limits it generally reports the node’s core count rather than your allocation, so a 2 vCPU pod on a 64-core node will happily start 64 workers, each with its own pool. That single line turns a comfortable budget into an outage.

Why the obvious fix is a trap

The arithmetic suggests an obvious response: lower the pool limit until the product fits. That works, right up until it doesn’t, because the pool limit is not free to shrink. Each worker’s pool is what lets it overlap concurrent requests against the database. Drive it to two and you have not solved the connection problem — you have converted it into a queueing problem inside every worker, where it is much harder to see. The calculator above says “fits” and then tells you it starves; both are true.

That is the squeeze. Above some fleet size there is no pool limit that is simultaneously small enough for the ceiling and large enough for the worker. The honest read is that per-worker pooling has stopped being the right architecture, not that you picked a bad number.

RDS Proxy, and the trap inside that

Amazon RDS Proxy is the structural answer: it holds the database connections and multiplexes many client connections over them, so task count stops translating one-for-one into backend connections. It is the right tool. It also has a failure mode that will quietly give you none of the benefit.

When RDS Proxy cannot safely hand a database connection to a different client — because the session carries state that would leak — it pins the client to that connection for the rest of the session. A pinned connection is an ordinary connection with extra hops. Pin everything and you have paid for a proxy that multiplexes nothing.

For Aurora PostgreSQL, AWS documents the pinning triggers. Abridged to the ones an application framework hits by accident:

TriggerWhere it usually comes from
SET commands, or set_configSession setup: timezone, search_path, statement timeouts
PREPARE, EXECUTE, DEALLOCATE, DISCARDPrepared-statement management in the driver
Temporary tables, sequences or viewsMigration tooling, some ORM query patterns
Declaring cursorsStreaming large result sets
nextval / setvalSequence manipulation outside an insert
pg_advisory_lockApplication-level locking, leader election, migrations
Listening on a notification channelLISTEN/NOTIFY job queues

Two entries deserve to be pulled out.

DISCARD ALL as a pool reset query. Several connection-pooling libraries are configured to issue DISCARD ALL when returning a connection to the pool. It is a sensible hygiene default, and against RDS Proxy it pins your client connection on release. The library that exists to conserve connections is what stops the proxy conserving them.

Advisory locks, but not all of them. pg_advisory_lock pins. The transaction-scoped variants — pg_advisory_xact_lock, pg_try_advisory_xact_lock and their shared forms — do not, because their state cannot outlive the transaction. If you hold advisory locks for migrations or leader election, that is a small change with a large effect.

There is also a piece of good news that invalidates older advice. Pinning on PostgreSQL Extended Query Protocol — the A parse message was detected log line, which many drivers and ORMs triggered on essentially every query — is handled: RDS Proxy multiplexes Extended Query Protocol automatically, with nothing to enable. If you evaluated RDS Proxy behind a modern driver a while ago, measured near-total pinning and concluded it was incompatible, that conclusion is worth re-testing.

What to do, in order

This ordering is my engineering assessment rather than a benchmark. It runs cheapest and most reversible first.

  1. Compute the product. Tasks at maximum scale, times workers per task, times pool limit. Compare against SHOW max_connections;, not against the instance class you remember buying. Most teams have never written this number down.
  2. Set the worker count explicitly. Never $(nproc) in a container.
  3. Get writes off the request path. Anything appended per request — spend ledgers, audit rows, metrics — should batch on an interval or buffer through a cache rather than opening the transaction inline. This usually buys more headroom than pool tuning does, because it shortens how long each connection is held.
  4. Tune the pool limit to the 80% budget, and treat a limit at or below two as a signal that you have outgrown per-worker pooling rather than as a solution.
  5. Add RDS Proxy, then immediately check the pinning ratio under load. Fix the reset query and the advisory-lock variants before concluding it doesn’t work.
  6. Only then consider a larger instance class, remembering the 5,000 ceiling is waiting at the end of that road.

The general lesson is smaller than the mechanics: when a system autoscales one side of a relationship and not the other, the fixed side becomes the capacity limit, and it will be discovered at the worst possible moment by the autoscaler itself. Connection counts are the most common instance of that pattern, not the only one.

Sources


Share this post:

Previous Post
Customizing inference on AgentCore Gateway: a plugin pipeline for routing, budgets and guardrails
Next Post
Semantic Caching for LLMs on AWS