Back to Blog
Kubernetes
Databases
DevOps

Scaling Databases on Kubernetes

OpsQuiz TeamAugust 4, 20264 min read23 views

Scaling a stateless web app on Kubernetes is almost a solved problem: add more replicas, put a Service in front of them, done. Scaling a database is a completely different challenge, and the instinct to reach for the same tools (a Horizontal Pod Autoscaler, more replicas) doesn't map cleanly onto something that has to keep a single consistent copy of data correct across every instance.

Why you can't just add replicas to a database Pod

A stateless app's replicas are interchangeable, any of them can answer any request, so a load balancer can spread traffic across them arbitrarily. A database's replicas are not interchangeable in the same way. There's normally one primary, which is the only instance allowed to accept writes, and some number of replicas, which receive a continuous stream of changes from the primary and can serve reads, but aren't authoritative for writes.

This means "scaling a database" almost always means something more specific than "add more Pods": it means scaling reads (adding more replicas to spread SELECT queries across), scaling connections (handling more concurrent clients without exhausting the database's own connection limit), or scaling writes (which is the genuinely hard problem, usually solved by sharding rather than simple replication).

Read scaling: replicas plus a way to route to them

On Kubernetes, database replication is usually managed by a dedicated (the CloudNativePG operator for Postgres, or Vitess for MySQL, being two well-known examples) rather than hand-rolled StatefulSets, because correctly wiring up replication, failover, and backups by hand is a lot of easy-to-get-wrong plumbing that these operators already handle.

Once you have a primary and replicas, you still need application code (or a proxy sitting in front of the database) that knows to send writes to the primary and reads to a replica. This routing logic is something a stateless app's load balancer gives you for free and a database absolutely does not, it has to be explicitly built or configured.

Connection pooling: the scaling problem nobody expects

Every Postgres connection is a real operating system process with real memory overhead, which is why max_connections exists and is lower than most people expect on first encounter. Kubernetes makes this worse by default: if your application scales out to twenty Pods, each opening ten connections "just in case," that's two hundred connections for a workload that might only need twenty active at any given moment.

The standard fix is a connection pooler, PgBouncer being the common choice for Postgres, deployed as a sidecar or a small dedicated Deployment that sits between your application Pods and the database, multiplexing many application-side connections down to a much smaller number of real database connections.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: pgbouncer
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: pgbouncer
          image: pgbouncer/pgbouncer
          env:
            - name: DATABASES_HOST
              value: postgres-primary
            - name: POOL_MODE
              value: transaction

Without a pooler in place, "scaling" your application by adding more Pods can actually make the database slower, since the database is now spending resources managing far more idle connections than it needs to.

Vertical scaling: resizing the storage and compute underneath

Sometimes the actual answer isn't more replicas at all, it's giving the existing instance more resources. On Kubernetes this means resizing the PersistentVolume backing the database (many storage classes, including Longhorn, support online volume expansion without downtime) and adjusting CPU/memory requests and limits on the StatefulSet. A VerticalPodAutoscaler can be run in recommendation-only mode to tell you what the right numbers actually are, based on observed usage, rather than guessing.

Sharding: the option you reach for last

If a single primary genuinely can't handle the write volume no matter how it's tuned, the remaining option is sharding, splitting data across multiple independent database instances by some key (customer ID, region, and so on), so writes are distributed rather than all landing on one primary. This solves the scaling problem but introduces real complexity: cross-shard queries and transactions become genuinely hard, and re-sharding as your key distribution shifts over time is a significant undertaking. Tools like Vitess exist specifically to make MySQL sharding on Kubernetes more manageable, but sharding is still the option to reach for last, after read replicas and connection pooling have been fully explored, not the first thing to try.

What NOT to automate

Resist the urge to put a plain HorizontalPodAutoscaler on a database StatefulSet the same way you would a web app. Adding a database replica isn't like starting a new stateless Pod, it involves an initial data sync that can take real time and load on the primary, and removing one isn't a clean, instant operation either. Database scaling decisions generally deserve a human (or a purpose-built operator that understands the specific database's replication mechanics) in the loop, not a generic autoscaler reacting to a CPU metric.

Getting comfortable with the difference between stateless and stateful scaling is one of the more valuable distinctions in real-world Kubernetes work. The Kubernetes quiz on OpsQuiz covers the underlying StatefulSet and storage concepts this all builds on.

    Welcome to OpsQuiz!

    Real scenario-based DevOps questions, hands-on practice, and clear explanations for every answer.