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
- Horizontal and vertical scaling
- What scales and what does not in Aurora Libros
- The real bottleneck: PostgreSQL connections
- Read replicas and PgBouncer
- The prerequisite: a stateless application
- Load balancing: L4 and L7
- Internal balancing: Service,
kube-proxy, iptables and IPVS - Ingress balancing and the Compose/Swarm world
- Algorithms and session affinity
- Manual scaling, compared
- The
HorizontalPodAutoscalerin depth - The HPA for
aurora-api - Custom metrics
VerticalPodAutoscalerandCluster Autoscaler- Availability: PDBs, spreading and anti-affinity
- Load testing and reading the limits
- 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.
- 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.
- 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.
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.
- 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.
- 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.
- 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.
- Internal balancing: Service,
kube-proxy, iptables and IPVS
kube-proxy, iptables and IPVSWhen 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 nodeWith 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.
- 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.
- 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:
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.
- 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 -wA 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.
- The
HorizontalPodAutoscaler in depth
HorizontalPodAutoscaler in depthThe HPA is just another controller: every 15 seconds it reads the metrics, applies a formula and adjusts spec.replicas.
| 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
- The HPA for
aurora-api
aurora-apiapiVersion: 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 minuteThe 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.
- 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.
VerticalPodAutoscaler and Cluster Autoscaler
VerticalPodAutoscaler and Cluster AutoscalerThe 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]
- 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.
- 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);
}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 × poolbefore settingmaxReplicas. - 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 podsis 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.
minAvailableequal toreplicasin 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
maxReplicasto a number the cluster can actually host. Otherwise you getPendingPods 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 | auroraThree 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"}
9Nine 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 -cA 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 auroraevicting 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 4The 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
- What Is Docker?
- Installing Docker
- Docker Architecture
- Basic Docker Commands
- Understanding Docker Images
- Creating Your First Docker Container
- The Course Project: The Aurora Libros Platform
Module 2: Working with Docker Images
- Docker Hub and Repositories
- Building Docker Images
- Dockerfile Basics
- Advanced Dockerfile Instructions
- Managing Docker Images
- Tagging and Publishing Images
Module 3: Docker Containers
- Running Containers
- Container Lifecycle
- Managing Containers
- Inspecting and Debugging Containers
- Docker Networking
- Data Persistence with Volumes
- Resource Limits and Restart Policies
Module 4: Docker Compose
- Introduction to Docker Compose
- Defining Services in Docker Compose
- Docker Compose Commands
- Multi-Container Applications
- Environment Variables in Docker Compose
- Profiles, Overrides and Multiple Environments
- Local Development with Docker Compose
Module 5: Advanced Docker Concepts
- Docker Networking Deep Dive
- Docker Storage Options
- Docker Security Best Practices
- Optimizing Docker Images
- Advanced Builds with BuildKit and Buildx
- Logging and Monitoring in Docker
- The Runtime Inside: Namespaces, Cgroups and Layers
Module 6: Docker in Production
- Preparing an Image for Production
- CI/CD with Docker
- Orchestrating Containers with Docker Swarm
- Introduction to Kubernetes
- Deploying Docker Containers in Kubernetes
- Scaling and Load Balancing
- Deployment Strategies and Rollback
