Aurora Libros lives in a cluster with three fixed API replicas. It works fine at four in the afternoon and chokes the day a newspaper reviews La sombra del viento. This lesson makes the platform grow by itself with the load, distribute traffic sensibly, and let you see, with numbers, where the real limit is.

Contents

  1. Horizontal and vertical scaling
  2. What scales and what does not in Aurora Libros
  3. The real bottleneck: PostgreSQL connections
  4. Read replicas and PgBouncer
  5. The prerequisite: a stateless application
  6. Load balancing: L4 and L7
  7. Internal balancing: Service, kube-proxy, iptables and IPVS
  8. Ingress balancing and the Compose/Swarm world
  9. Algorithms and session affinity
  10. Manual scaling, compared
  11. The HorizontalPodAutoscaler in depth
  12. The HPA for aurora-api
  13. Custom metrics
  14. VerticalPodAutoscaler and Cluster Autoscaler
  15. Availability: PDBs, spreading and anti-affinity
  16. Load testing and reading the limits

  1. Horizontal and vertical scaling

Aspect Vertical (scale up) Horizontal (scale out)
What changes More CPU and RAM on the same instance More identical instances
Limit The largest node that exists Practically none
Cost It jumps in steps, not linearly Linear and granular
Fault tolerance None: a single point High: some fall, others remain
Requires a restart Yes, almost always No
Reaction time Minutes Seconds
Fits Databases, caches Stateless services
In Kubernetes VerticalPodAutoscaler HorizontalPodAutoscaler

Horizontal is the natural one with containers for a simple reason: starting another replica of aurora-api:2.0.0 takes seconds and requires nobody's permission, whereas growing a machine means stopping it. And it brings something vertical never does: redundancy. Three replicas do not just take three times the load, they survive losing one.

  1. What scales and what does not in Aurora Libros

Service Does it scale horizontally? Why
aurora-web Yes, trivially It serves static files, no state
aurora-api Yes Stateless: the cache is outside, in aurora-cache
aurora-cache Only with Redis Cluster The data is sharded, not duplicated
aurora-db Not just like that Writes to a single primary; ReadWriteOnce PVC

The asymmetry in the last row is the reality of almost every platform: the front end scales with a number, and the database demands architecture. And since all the traffic ends up going through it, the system's limit is its limit.

  1. The real bottleneck: PostgreSQL connections

Every PostgreSQL connection is an operating system process with several megabytes of memory of its own. The max_connections parameter defaults to 100, and that number is not a recommendation: it is a wall.

api_replicas × DB_POOL_MAX  ≤  max_connections − reserved_for_superuser

With the DB_POOL_MAX: "10" from your ConfigMap and max_connections = 100:

API replicas Open connections Result
3 30 Fine
8 80 At the limit
10 100 FATAL: sorry, too many clients already
20 (autoscaled) 200 The database rejects half of them

This is the trap that sinks a lot of people's first autoscaling attempt: the HPA does its job, creates twenty replicas to absorb the peak... and the whole platform goes down, because twenty pools of ten connections flatten PostgreSQL. Scaling the API without looking at the database does not spread the load: it relocates the failure.

  1. Read replicas and PgBouncer

There are two solutions and they complement each other.

Read replicas. PostgreSQL replicates by streaming to read-only replicas. aurora-api sends the catalog's SELECTs to a replica and the writes to the primary. It works very well in Aurora Libros because its load is almost entirely reads, but it introduces replication lag: a freshly inserted book can take milliseconds to appear on the replica, so whichever operation has just written must read from the primary.

PgBouncer. A pooler that sits in front and multiplexes: a thousand client connections over twenty real ones.

# k8s/base/pgbouncer.yaml (extract)
        - name: pgbouncer
          image: bitnami/pgbouncer:1.23
          env:
            - { name: POSTGRESQL_HOST,   value: aurora-db }
            - { name: PGBOUNCER_POOL_MODE, value: transaction }   # the mode that shares the most
            - { name: PGBOUNCER_MAX_CLIENT_CONN, value: "1000" }
            - { name: PGBOUNCER_DEFAULT_POOL_SIZE, value: "20" }
Pool mode When the connection is returned Multiplexing Limitation
session When the client disconnects Poor Barely better than nothing
transaction When each transaction finishes High No prepared statements or session-level SET
statement After every statement Maximum No multi-statement transactions

With transaction and DB_HOST: pgbouncer, aurora-api can scale to fifty replicas while keeping twenty real connections against PostgreSQL. It is, by a wide margin, the intervention with the best effort-to-result ratio in this lesson.

  1. The prerequisite: a stateless application

A replica is only interchangeable if it holds nothing the others do not have. The practical rule: if you shut down any Pod and no user notices, the application is stateless.

State Where it CANNOT live Where it goes in Aurora Libros
User session The process's memory aurora-cache (shared Redis)
Shopping cart A global variable aurora-cache with a TTL
Catalog cache Local memory aurora-cache, shared by all
Uploaded files The container's disk Object storage
Counters and metrics A local variable Prometheus, aggregating per replica

Keeping the session in memory produces the most baffling failure on a scaled platform: the user logs in (they hit replica 1), browses (replica 2) and appears logged out. It is exactly the problem that having the cache outside the process solves, and that is why aurora-cache is not a performance luxury: it is the requirement that makes replicating the API possible.

  1. Load balancing: L4 and L7

Level What it inspects Can distribute by Cost Examples
L4 (transport) IP and port TCP connection Very low kube-proxy, IPVS, HAProxy in TCP mode
L7 (application) Headers, path, method, cookies HTTP request Higher Ingress-nginx, Traefik, Envoy

The practical difference shows up with persistent connections. An L4 balancer distributes connections, not requests: if a client opens a keep-alive connection and sends a thousand requests, all thousand go to the same Pod. With ordinary HTTP/1.1 clients you barely notice, but with gRPC or HTTP/2 —a single long-lived connection— L4 distribution becomes terribly uneven, and that is why that kind of traffic needs an L7 balancer.

  1. Internal balancing: Service, kube-proxy, iptables and IPVS

When aurora-api resolves aurora-cache, it gets a virtual IP that does not exist on any network interface: it is a fictitious address that kube-proxy intercepts with kernel rules.

flowchart LR
    P["Pod aurora-api"] -->|10.96.184.22:6379| K["kube-proxy<br/>iptables / IPVS"]
    K -->|DNAT 33%| A[Pod cache-1]
    K -->|DNAT 33%| B[Pod cache-2]
    K -->|DNAT 33%| C[Pod cache-3]
kube-proxy mode How it distributes Complexity Algorithms
iptables (default) Probability-based rules O(n): degrades with thousands of services Random only
IPVS A hash table in the kernel O(1) rr, lc, sh, dh, wrr
nftables The modern replacement for iptables O(1) Random
kubectl get svc aurora-api -n aurora -o jsonpath='{.spec.clusterIP}'
kubectl get endpointslices -n aurora -l kubernetes.io/service-name=aurora-api
sudo iptables -t nat -L KUBE-SVC-XXXX -n   # inside the node

With iptables, distribution is implemented by chaining rules with decreasing probabilities: the first accepts with probability 1/3, the next takes 1/2 of what is left, and the last one picks up the remainder. The result is an even spread, but a random and memoryless one: it does not know how many connections each Pod has or how long each takes to answer. To get least-connections distribution you have to move to IPVS. And for real policies —retries, circuit breakers, latency-based distribution— you need a service mesh like Istio or Linkerd.

  1. Ingress balancing and the Compose/Swarm world

The Ingress operates at L7 and its controller talks directly to the Pods, bypassing the Service's virtual IP: it reads the EndpointSlices and balances by itself, which lets it use algorithms and retries kube-proxy does not have.

  annotations:
    nginx.ingress.kubernetes.io/upstream-hash-by: "$request_uri"   # distribution by URI
    nginx.ingress.kubernetes.io/proxy-next-upstream: "error timeout http_502"
    nginx.ingress.kubernetes.io/load-balance: "ewma"               # moving latency
Environment Who balances Default algorithm
Compose with --scale Docker's DNS: it returns the IPs rotated Weak round-robin (the client caches)
Swarm with ingress IPVS on every node Round-robin
Swarm with your own Nginx Your upstream Whatever you configure
Kubernetes, internal kube-proxy Random (iptables)
Kubernetes, ingress The ingress controller Round-robin, ewma, hash

The Compose case deserves a warning: with --scale api=3, the internal DNS returns all three IPs, but the client decides which one it uses and many HTTP clients cache the first resolution forever. That is why a docker compose up --scale with no Nginx in front distributes far worse than it appears to.

  1. Algorithms and session affinity

Algorithm How it chooses When it works well Risk
Round-robin By turns Homogeneous requests Ignores the real load
Least connections The idlest backend Requests of uneven duration It needs state
IP / URI hash Deterministic by key Caches, affinity Uneven distribution
EWMA / latency The fastest one lately Heterogeneous backends It can oscillate
Random with two choices Pick 2 and take the less loaded Scales very well

Session affinity ties each client to a fixed backend:

spec:
  sessionAffinity: ClientIP
  sessionAffinityConfig: { clientIP: { timeoutSeconds: 10800 } }

It looks like the easy fix for the in-memory session problem, and it is a bad bargain. It breaks the distribution (a corporate proxy with a thousand employees is one single IP, and all of them land on the same Pod), it ruins scaling (new Pods do not receive existing traffic, so scaling relieves nothing immediately) and it turns every deployment into a loss of sessions. The correct solution is still the one from section 5: get the state out of the process. Aurora Libros does not use session affinity.

  1. Manual scaling, compared

Platform Command Reconciliation
Compose docker compose up -d --scale aurora-api=5 Recreates containers; no scheduler
Swarm docker service scale aurora_aurora-api=5 Spreads across nodes according to constraints
Kubernetes kubectl scale deploy/aurora-api --replicas=5 The scheduler places by requests
Kubernetes (conditional) kubectl scale --current-replicas=3 --replicas=5 Only if the current number is the expected one
kubectl scale deploy/aurora-api -n aurora --replicas=6
kubectl get pods -n aurora -l app.kubernetes.io/name=aurora-api -w

A warning that saves you a fright: if an HPA governs the Deployment, a manual kubectl scale lasts until the autoscaler's next cycle, some fifteen seconds. It is not a bug; it is that the desired state is now set by the HPA.

  1. The HorizontalPodAutoscaler in depth

The HPA is just another controller: every 15 seconds it reads the metrics, applies a formula and adjusts spec.replicas.

desired_replicas = ceil( current_replicas × (current_metric / target_metric) )
Current Average CPU usage Target Calculation Desired
3 90 % 70 % ceil(3 × 90/70) = ceil(3.86) 4
4 140 % 70 % ceil(4 × 2) 8
8 20 % 70 % ceil(8 × 0.29) 3
3 72 % 70 % 3 × 1.03 → within the tolerance (10 %) 3

That 10 % tolerance matters: without it, the HPA would react to every minor fluctuation and the platform would never stop creating and destroying Pods.

Prerequisite: metrics-server installed and requests defined. The HPA's percentage is relative to the requests, not to the limit or the node's capacity. A Deployment with no requests leaves the HPA without a denominator and its status shows up as <unknown>; it is the number one cause of "my HPA does nothing".

kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
kubectl top pods -n aurora

  1. The HPA for aurora-api

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: aurora-api, namespace: aurora }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: aurora-api }
  minReplicas: 3
  maxReplicas: 12          # 12 × 10 connections = 120 > max_connections: see section 3
  metrics:
    - type: Resource
      resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
    - type: Resource
      resource: { name: memory, target: { type: Utilization, averageUtilization: 80 } }
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 0        # go up fast: the user is waiting
      policies:
        - { type: Percent, value: 100, periodSeconds: 30 }   # at most, double every 30 s
        - { type: Pods,    value: 4,   periodSeconds: 30 }
      selectPolicy: Max
    scaleDown:
      stabilizationWindowSeconds: 300      # come down slowly: 5 min of observation
      policies:
        - { type: Percent, value: 25, periodSeconds: 60 }    # at most, -25 % per minute

The behavior block is what avoids the yo-yo effect: without it, a burst creates replicas, average CPU drops, the HPA destroys them, the load concentrates again and the cycle repeats indefinitely, with Pods being born and dying without ever becoming useful.

The asymmetry is deliberate and worth committing to memory: scaling up is urgent, scaling down is not. Under-scaling costs you lost requests and customers; over-scaling costs a few cents of idle CPU for five minutes. The 300-second stabilization window makes the HPA use the maximum of the recommendations from the last five minutes before it reduces anything.

And maxReplicas: 12 is not a round number: it comes out of the arithmetic in section 3. Twelve replicas times ten connections is 120, already over max_connections. With PgBouncer in front, that ceiling could go up comfortably.

  1. Custom metrics

CPU is a poor indicator for an API that spends a lot of time waiting on I/O: aurora-api can sit at 20 % CPU with latency through the roof waiting on PostgreSQL. What really describes its load is requests per second and latency.

    - type: Pods
      pods:
        metric: { name: http_requests_per_second }
        target: { type: AverageValue, averageValue: "50" }

That requires an adapter to expose the Prometheus metrics through the custom.metrics.k8s.io API (prometheus-adapter or KEDA). The full circuit is: your API exposes /metrics → Prometheus collects it (you already set that up in 05-06) → the adapter publishes it as a Kubernetes metric → the HPA consumes it. It is worth doing when CPU consumption does not correlate with the perceived load, which is the case for almost any database-bound API.

  1. VerticalPodAutoscaler and Cluster Autoscaler

The VPA adjusts requests and limits instead of the number of replicas. Its great value lies in Off mode, where it only recommends: it tells you, with weeks of data, what values your containers ought to have, so you can adjust the manifests without guessing. In Auto mode it recreates the Pods to apply the new values, which makes it incompatible with an HPA on the same metric: if both act on CPU, they fight.

The Cluster Autoscaler works one level up: when there are Pending Pods because they do not fit on any node, it asks the cloud provider for a new machine; when a node has been underused for a while and its Pods fit elsewhere, it drains it and shuts it down. It is the piece that keeps the HPA from hitting the capacity ceiling, and also the one that introduces the real scaling delay: creating a node takes minutes, not seconds.

flowchart TB
    C[Load rises] --> H[HPA: more Pods]
    H --> Q{Do they fit?}
    Q -->|yes| OK[Scheduled in seconds]
    Q -->|no| P[Pods Pending]
    P --> CA[Cluster Autoscaler: a new node]
    CA --> OK2[Scheduled in minutes]

  1. Availability: PDBs, spreading and anti-affinity

Scaling is worth nothing if all twelve replicas are on the same node and that node goes down.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: aurora-api }
spec:
  minAvailable: 2                # never fewer than 2 during maintenance
  selector: { matchLabels: { app.kubernetes.io/name: aurora-api } }
      topologySpreadConstraints:
        - maxSkew: 1                                  # at most 1 Pod of difference
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway           # a preference, not a requirement
          labelSelector: { matchLabels: { app.kubernetes.io/name: aurora-api } }
Mechanism What it guarantees What it protects against
PodDisruptionBudget A minimum available during voluntary disruptions kubectl drain, node upgrades
topologySpreadConstraints A balanced spread across nodes or zones The loss of a node or a zone
podAntiAffinity Replicas on different nodes (a hard or soft rule) The loss of a node

The PDB only covers voluntary disruptions: if a node switches off abruptly, nobody asks permission. And there is a classic trap: minAvailable: 2 with replicas: 2 blocks any drain forever, because not one Pod can be removed without violating it. Always express the PDB in terms of the HPA's minimum, not the current number.

  1. Load testing and reading the limits

apiVersion: batch/v1
kind: Job
metadata: { name: load-books, namespace: aurora }
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: k6
          image: grafana/k6:latest
          args: ["run","--vus","200","--duration","5m","/scripts/load.js"]
          volumeMounts: [{ name: scripts, mountPath: /scripts }]
      volumes: [{ name: scripts, configMap: { name: k6-scripts } }]
// load.js — 200 virtual users requesting the catalog
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = { thresholds: { http_req_duration: ['p(95)<800'] } };
export default function () {
  const r = http.get('http://aurora-api:3000/books');
  check(r, { 'status 200': (x) => x.status === 200 });
  sleep(0.2);
}
kubectl apply -f k8s/load.yaml
kubectl get hpa aurora-api -n aurora -w
NAME         TARGETS              MINPODS  MAXPODS  REPLICAS
aurora-api   12%/70%, 31%/80%     3        12       3
aurora-api   148%/70%, 44%/80%    3        12       3
aurora-api   148%/70%, 44%/80%    3        12       6
aurora-api   96%/70%, 41%/80%     3        12       9
aurora-api   64%/70%, 38%/80%     3        12       9
aurora-api   9%/70%, 30%/80%      3        12       3
Moment Replicas Average CPU p50 p95 Errors
Idle (20 req/s) 3 12 % 8 ms 24 ms 0
Peak, before the HPA 3 148 % 310 ms 1,840 ms 12
While scaling 6 96 % 96 ms 420 ms 0
Stabilized 9 64 % 21 ms 88 ms 0
After the load (5 min) 3 9 % 8 ms 22 ms 0

The p95 went from 1,840 ms to 88 ms without anybody touching anything: the HPA did it in a little over a minute. Look at the peak row: 148 % utilization means each Pod is using 1.48 times its CPU request, that is, it is being throttled against its limit; the twelve errors are requests that exceeded the client's timeout.

Symptom under load Bottleneck What to do
API CPU maxed out, DB relaxed The API Scale replicas: the HPA covers it
API idle, high latency, DB at 100 % The database Read replicas, indexes, more caching
too many clients already Connections PgBouncer, lower DB_POOL_MAX
Everything idle and still slow The network or an external dependency Distributed tracing, check DNS and keep-alive
Memory growing until OOMKilled A leak in the application Profile the heap; the HPA does not fix a leak
Low cache hit ratio The TTL or the cache key Review CACHE_TTL and the key

The last row of the previous block is the most important thing in the lesson: scaling the API only helps when the bottleneck is the API. In every other case, adding replicas makes the problem worse, because it multiplies the pressure on the component that was already saturated.

Warning. A load test against a shared environment can degrade other teams' services and set off security alerts. Always agree the window, the traffic source and the thresholds with the infrastructure and security officers, and never fire load at systems you do not control.

Common Mistakes and Tips

  • Scaling the API without looking at the database. The HPA creates twenty replicas, PostgreSQL rejects connections and the outage is total. Work out replicas × pool before setting maxReplicas.
  • An HPA with no requests. The targets show up as <unknown> and it never scales. The percentage is relative to the request.
  • No metrics-server. The HPA has nothing to read from. kubectl top pods is the thirty-second check.
  • Aggressive scaleDown. With no stabilization window, the platform oscillates and no replica ever becomes useful.
  • Session affinity as a patch. It breaks the distribution and does not fix the design. Get the state out of the process.
  • minAvailable equal to replicas in a PDB. It blocks every cluster maintenance operation.
  • Load-testing an API with a warm cache. You are measuring Redis, not your system. Flush the cache or vary the keys.
  • Tip: measure before and after with the same percentiles. The p95 and the p99 tell the story; the average hides it.
  • Tip: set maxReplicas to a number the cluster can actually host. Otherwise you get Pending Pods and autoscaling that looks like it works while doing nothing.

Exercises

Exercise 1. Calculate and demonstrate the connection limit: find out aurora-db's max_connections, work out how many API replicas fit with DB_POOL_MAX: 10 and trigger the failure by scaling beyond that number.

Exercise 2. Set up the HPA over aurora-api, generate load with k6 and document the table of latencies and replicas before, during and after. Explain why coming down takes far longer than going up.

Exercise 3. Check that the replicas are spread out and that the platform survives a maintenance window: apply a PDB and topologySpreadConstraints, drain a node and observe what Kubernetes does.

Solutions

Solution 1.

kubectl exec -n aurora aurora-db-0 -- psql -U aurora -tAc 'SHOW max_connections;'
kubectl exec -n aurora aurora-db-0 -- psql -U aurora -tAc \
  "SELECT count(*), usename FROM pg_stat_activity WHERE usename='aurora' GROUP BY usename;"
# 100
# 30 | aurora

Three replicas times ten connections are exactly the 30 that are open: the ConfigMap's DB_POOL_MAX is not theoretical, each Pod opens its full pool at startup even if it does not use it. The theoretical ceiling is (100 − 3 reserved) / 10 = 9 replicas.

kubectl scale deploy/aurora-api -n aurora --replicas=12
sleep 30
kubectl logs -n aurora -l app.kubernetes.io/name=aurora-api --tail=2 | grep -i fatal
kubectl get pods -n aurora -l app.kubernetes.io/name=aurora-api | grep -c Running
{"level":"error","message":"connection failed",
 "detail":"FATAL: sorry, too many clients already"}
9

Nine Running Pods out of twelve, with the other three in CrashLoopBackOff: their pools never managed to open. But the serious part is not those three, it is the effect on the ones that did start, which begin to see intermittent errors as they renew connections.

kubectl apply -f k8s/base/pgbouncer.yaml
kubectl patch cm aurora-config -n aurora --type=merge -p '{"data":{"DB_HOST":"pgbouncer"}}'
kubectl rollout restart deploy/aurora-api -n aurora
kubectl exec -n aurora aurora-db-0 -- psql -U aurora -tAc \
  "SELECT count(*) FROM pg_stat_activity WHERE usename='aurora';"
# 20
Configuration API replicas Real connections to PostgreSQL Replica ceiling
Direct 12 120 (rejected) 9
With PgBouncer (transaction, pool 20) 12 20 Hundreds

Twelve replicas and twenty real connections: PgBouncer has completely decoupled the API's scaling from the database's limit. That is why maxReplicas stops being a connection calculation and can be set by compute capacity.

The underlying lesson is one of method: a system's limit is almost never where you are scaling. Here you were scaling the API and the thing that broke was PostgreSQL, a service you had not even touched. Before raising maxReplicas, you always have to ask which shared resource gets multiplied by every replica.

Solution 2.

kubectl apply -f k8s/hpa.yaml
kubectl get hpa aurora-api -n aurora
kubectl apply -f k8s/load.yaml           # the k6 Job with 200 virtual users
kubectl get hpa aurora-api -n aurora -w &
kubectl logs -f job/load-books -n aurora | tail -6
     http_req_duration..: p(50)=21ms  p(95)=88ms  p(99)=141ms
     http_req_failed....: 0.00%  ✓ 0  ✗ 74213
     iterations.........: 74213  247.3/s
     ✓ status 200
Phase t Replicas CPU (% of request) p95 Errors
Idle 0 3 12 % 24 ms 0
Load starts +15 s 3 148 % 1,840 ms 12
First scale-up +45 s 6 96 % 420 ms 0
Second scale-up +75 s 9 64 % 88 ms 0
Load ends +5 min 9 9 % 22 ms 0
Scale-down +10 min 3 9 % 22 ms 0

The two right-hand columns sum up the result: the p95 fell from 1,840 ms to 88 ms, twenty times over, without anybody touching a thing, and the twelve errors in the first minute are the price of having started with three replicas.

The timings reveal the asymmetry of behavior. Going up happened in two 30-second jumps —stabilizationWindowSeconds: 0 and the at-most-double policy— and within 75 seconds the platform had stabilized. Coming down started five minutes after the load finished, because scaleDown.stabilizationWindowSeconds: 300 forces the HPA to use the maximum recommendation in that window; and then it came down slowly, 25 % per minute.

That slowness is intentional, and the justification is economic before it is technical. Coming down fast carries an asymmetric risk: if the load rises again thirty seconds later —which is very common, because traffic arrives in bursts—, you are back to three replicas choking and another minute of bad latencies. Keeping nine replicas five minutes longer than necessary costs cents; falling short on the second peak costs customers.

One methodological detail: the 247 iterations per second in the report are the sustained throughput, not the peak. When measuring, that number alongside the p95 matters far more than the instantaneous maximum, which is almost always achieved at the cost of unacceptable latencies.

Solution 3.

kubectl apply -f k8s/pdb.yaml
kubectl get pods -n aurora -l app.kubernetes.io/name=aurora-api \
  -o custom-columns=POD:.metadata.name,NODE:.spec.nodeName --no-headers | awk '{print $2}' | sort | uniq -c
      3 aurora-worker
      3 aurora-worker2

A perfect spread thanks to topologySpreadConstraints with maxSkew: 1. Without that constraint, the scheduler only looks at free resources and is perfectly capable of putting all six on the same node if they fit there.

kubectl drain aurora-worker --ignore-daemonsets --delete-emptydir-data --timeout=120s
kubectl get pods -n aurora -l app.kubernetes.io/name=aurora-api -o wide | tail -4
kubectl get pdb aurora-api -n aurora
evicting pod aurora/aurora-api-6d4f8b7c9-2xkpq
evicting pod aurora/aurora-api-6d4f8b7c9-mv7rn
aurora-api-6d4f8b7c9-k9wtz   1/1  Running  aurora-worker2
aurora-api-6d4f8b7c9-p2mvx   1/1  Running  aurora-worker2
NAME         MIN AVAILABLE   ALLOWED DISRUPTIONS
aurora-api   2               4

The detail to watch is that the evictions happened one at a time and with waiting in between. A drain does not evict all the node's Pods at once: it asks the PDB for permission before each one, and if evicting the next would leave fewer than two available, it waits for the replacement to be ready on another node. That is why ALLOWED DISRUPTIONS keeps dropping during the operation.

# With a badly calibrated PDB, the drain blocks forever
kubectl patch pdb aurora-api -n aurora -p '{"spec":{"minAvailable":6}}'
kubectl drain aurora-worker2 --ignore-daemonsets --timeout=30s
# error: Cannot evict pod ... violate the pod disruption budget.

There is the trap from section 15, live: with minAvailable: 6 and six replicas, no eviction is permissible and the drain fails indefinitely. During an overnight cluster upgrade, that PDB would leave the maintenance hanging with nobody understanding why.

The rule that follows: express the PDB in terms of the HPA's minimum, never the current number of replicas. With minReplicas: 3, a minAvailable: 2 (or better, maxUnavailable: 1) gives you room both at idle and at twelve replicas.

And the limitation this exercise does not cover: all of this protects against voluntary disruptions. If aurora-worker were switched off abruptly, its three Pods would vanish without the PDB saying a word, and only the spread across nodes —which you have guaranteed— would stop Aurora Libros from being left without a single live replica.

Conclusion

The platform now grows and shrinks by itself. You can tell horizontal scaling from vertical and you know which Aurora Libros services accept each: the API and the front end replicate freely because they hold no state, and the database does not, for two reasons you have measured —the ReadWriteOnce PVC and, above all, max_connections. That was the most valuable finding: the system's limit was not where you were scaling. Twelve replicas times ten connections flattened PostgreSQL, and PgBouncer in transaction mode solved it, leaving twelve replicas over twenty real connections, with read replicas as the other half of the answer.

You understand why the shared cache in aurora-cache is not a luxury but the requirement that makes replicating the API possible, and why session affinity is a patch that breaks the distribution instead of fixing the design. You know what separates L4 from L7, how kube-proxy implements the virtual IP with probabilistic iptables rules —and what IPVS gains you—, how the Ingress balances by talking directly to the EndpointSlices, and why a docker compose up --scale distributes worse than it appears to.

You have set up the HorizontalPodAutoscaler with its formula, its 10 % tolerance and a deliberately asymmetric behavior: up in seconds because the user is waiting, down over five minutes because traffic arrives in bursts. And you have tested it for real with k6: the p95 fell from 1,840 ms to 88 ms in a little over a minute while the HPA took the replicas from 3 to 9, and it went back to 3 on its own once it was over. You know about custom metrics as the next step when CPU does not describe the load, the VPA in recommendation mode and the Cluster Autoscaler with its delay measured in minutes. And you have secured availability with PodDisruptionBudget and topologySpreadConstraints, watching the drain evict one Pod at a time and seeing first-hand how a badly calibrated PDB blocks a maintenance window forever. Along with the table that translates each symptom into its real bottleneck, because scaling the API only helps when the problem is the API.

One last piece remains. Aurora Libros can take the load, but every time you release a new version there is still a delicate moment. In the next lesson, Deployment Strategies and Rollback, you will see how the old version and the new one coexist: rolling updates with maxSurge and maxUnavailable, blue-green, canary and shadow, with their cost and their risk; you will rehearse a failed deployment of aurora-api:2.1.0 that never passes readiness in order to watch it stop by itself and then undo it; and you will face the problem no strategy solves on its own, that of database migrations.

Docker: From Beginner to Advanced

Module 1: Introduction to Docker

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved