🏗️ Section 17 · Question #12

How to implement horizontal scaling of microservices

A microservice cannot be scaled horizontally if it retains conversational state in local JVM memory or on the local filesystem:


🟢 Junior Level

Horizontal Scaling (Scale Out) is the practice of increasing overall system capacity by adding multiple identical, stateless instances (replicas / pods) of a microservice behind a load balancer, as opposed to upgrading the CPU and RAM of a single machine (Vertical Scaling / Scale Up).

Vertical Scaling (Scale Up):
[Server 4 vCPU / 16 GB RAM] ──(Hardware replacement with downtime)──► [Server 32 vCPU / 128 GB RAM]

Horizontal Scaling (Scale Out):
                                               ┌──► [Pod 1 (4 vCPU / 16 GB)]
[Clients] ──► [Load Balancer / K8s Ingress] ───┼──► [Pod 2 (4 vCPU / 16 GB)]
                                               └──► [Pod 3 (4 vCPU / 16 GB)]

Key Advantages over Vertical Scaling

  1. No Hardware Ceiling: Vertical scaling hits an inescapable physical and financial ceiling when maximum server hardware configurations are reached. Horizontal scaling enables connecting hundreds of standard cloud VM instances.
  2. High Availability (Fault Tolerance): If a vertically scaled server suffers a hardware or kernel crash, the entire system goes down. In horizontal scaling, if 1 pod out of 10 crashes, the load balancer reroutes traffic to the surviving 9 pods with zero downtime.
  3. Elastic Cost Optimization: In modern cloud platforms (Kubernetes, AWS ECS), instances are dynamically instantiated during peak traffic surges and terminated when traffic subsides, minimizing operational cloud costs.

[!NOTE] Supermarket Checkout Analogy: Vertical scaling is hiring one superhuman cashier who scans items twice as fast (eventually hitting physical human limits). Horizontal scaling is opening 10 checkout lanes side by side, allowing you to open 5 additional lanes during rush hour and close them when quiet.


🟡 Middle Level

1. The Core Prerequisite: Strict Statelessness (12-Factor App)

A microservice cannot be scaled horizontally if it retains conversational state in local JVM memory or on the local filesystem:

Stateful Trap Architectural Problem Production Stateless Solution
In-Memory User Sessions Request 1 lands on Pod 1 (sets session); Request 2 lands on Pod 2 $\to$ HTTP 401 Unauthorized! Store sessions in a shared Redis Cluster (Spring Session Data Redis) or use stateless JWT Tokens.
Local File Uploads Files saved to /tmp/uploads on Pod 1 are invisible to Pod 2. Stream uploads directly to distributed object storage (Amazon S3, Google Cloud Storage, or MinIO).
Background Cron Tasks Spring @Scheduled tasks execute simultaneously on all 10 scaled pods, duplicating billing or emails! Guard schedulers with distributed locks (ShedLock) or migrate tasks to Kubernetes CronJob.

Distributed Scheduler Coordination with ShedLock

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class SchedulerConfig {

    // Ensures only 1 pod out of 20 replicas executes the cron job:
    @Scheduled(cron = "0 0 * * * *")
    @SchedulerLock(name = "hourlyBillingTask", lockAtLeastFor = "15s", lockAtMostFor = "5m")
    public void processHourlyBilling() {
        billingService.generateInvoices();
    }
}

2. Kubernetes Horizontal Pod Autoscaler (HPA v2) Configuration

In Kubernetes, autoscaling is managed declaratively by the Horizontal Pod Autoscaler (HPA) controller:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-service
  minReplicas: 3
  maxReplicas: 30
  metrics:
  # Metric 1: CPU Utilization
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  # Metric 2: Custom Prometheus Request Throughput (RPS)
  - type: Pods
    pods:
      metric:
        name: http_requests_per_second
      target:
        type: AverageValue
        averageValue: 500
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300 # Wait 5 minutes before terminating pods (prevents flapping)

🔴 Senior Level

1. The Database Connection Pool Exhaustion Hazard

The most common production outage during aggressive horizontal autoscaling is the database connection ceiling:

[50 Spring Boot Pods] ──(50 pods × 20 Hikari connections = 1,000 DB sockets!)──► [PostgreSQL Database]
                                                                                        │
                                                                   💥 max_connections = 500 reached!
                                                                   (Database rejects connections; CRASH)
  • Each Spring Boot pod defaults to hikari.maximum-pool-size: 20.
  • During peak traffic, HPA scales pods from 5 to 50 replicas.
  • 50 pods establish $50 \times 20 = 1,000$ active TCP sockets to PostgreSQL.
  • PostgreSQL exhausts its configured max_connections (typically 300–500), crashing the database and taking down all microservices across the enterprise.

The Senior Solution: Connection Pooling Proxies

Deploy a lightweight transaction-level connection pooler (PgBouncer or Odyssey for PostgreSQL, ProxySQL for MySQL, or AWS RDS Proxy) between the application pods and the database engine. PgBouncer multiplexes thousands of incoming application connections into a compact, stable pool of 50–100 physical database connections.

2. Event-Driven Autoscaling with KEDA (Kafka & RabbitMQ)

Standard CPU-based HPA fails for asynchronous messaging consumers:

  • A Kafka consumer pod pulls batches of 500 events, writes to S3, and incurs high I/O wait with low CPU utilization ($\sim 10\%$).
  • Meanwhile, an upstream surge causes the Kafka topic lag to spike from 0 to 5,000,000 unconsumed messages. HPA refuses to scale out because CPU remains low.
  • The Solution: KEDA (Kubernetes Event-Driven Autoscaling) scales consumer pods directly based on queue lag:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: kafka-consumer-scaler
spec:
  scaleTargetRef:
    name: order-consumer-deployment
  minReplicaCount: 2
  maxReplicaCount: 40
  triggers:
  - type: kafka
    metadata:
      bootstrapServers: kafka-cluster:9092
      consumerGroup: order-processing-group
      topic: order-events
      lagThreshold: "500" # Add 1 worker pod for every 500 pending messages in lag

3. Graceful Shutdown & Zero-Downtime Teardown

When HPA terminates pods during scale-down, in-flight HTTP requests must not be aborted with connection reset errors:

  1. Kubernetes sends SIGTERM to the pod and simultaneously removes the pod’s IP from the Service EndpointSlice.
  2. Spring Boot stops accepting new requests and awaits completion of active requests:
    server:
      shutdown: graceful # Waits for in-flight requests to complete
    spring:
      lifecycle:
        timeout-per-shutdown-phase: 30s # Hard limit before OS SIGKILL
    
  3. A Kubernetes preStop hook introduces a brief delay (sleep 5), providing sufficient time for kube-proxy across all cluster nodes to update their iptables/IPVS rules before the container process begins terminating.

4 Tricky Questions

1. How do you horizontally scale a stateful WebSocket microservice?

Answer: WebSocket connections are inherently stateful: a client maintains a persistent, open TCP socket with a specific container pod. If User A is connected to Pod 1 and User B is connected to Pod 2, Pod 1 cannot directly write frames to User B’s socket. Architectural Solution:

  1. Load Balancing: Ingress proxies (e.g., NGINX / AWS ALB) are configured to support the HTTP Upgrade: websocket handshake with long timeout thresholds.
  2. Distributed Message Broker Backplane: All microservice pods subscribe to a shared Pub/Sub message broker (Redis Pub/Sub, RabbitMQ topic exchange, or Kafka). When Pod 1 needs to dispatch a message to User B, it publishes an event containing the recipient ID to the Redis topic. All pods receive the event; Pod 2 detects that User B holds an active local socket and transmits the message over the local TCP connection.

2. Why is autoscaling Java microservices on JVM Memory Utilization considered a dangerous operational trap?

Answer: The Java Virtual Machine pre-allocates Heap memory on startup (-Xmx). Once the JVM allocates OS memory pages during runtime or after an initial traffic spike, it delays releasing physical memory back to the operating system until major garbage collection cycles occur. If an HPA rule targets memory averageUtilization: 75%, after the first morning traffic wave, memory will sit permanently at 80–90% even if the application is completely idle. The HPA controller will falsely interpret this as sustained load and inflate the deployment to maxReplicas, keeping dozens of idle pods running indefinitely and wasting thousands of dollars in cloud infrastructure. Best Practice: Autoscale Java microservices based on CPU utilization, HTTP Request Rate (RPS), or business metrics (Kafka lag).

3. How do you synchronize and invalidate local in-memory caches (Caffeine) when scaling across 20 pods?

Answer: If each pod maintains a fast in-memory Caffeine cache (e.g., caching product catalog prices), an update query routed to Pod 1 leaves stale data in the memory of the remaining 19 pods. Remedies:

  1. Two-Tier (L1/L2) Caching: L1 is a local Caffeine cache with a short TTL (e.g., 30–60 seconds); L2 is a centralized shared Redis cluster.
  2. Event-Driven Cache Eviction: Whenever an update occurs, the modifying pod publishes a CacheInvalidationEvent(key) to a Redis Pub/Sub topic or Kafka broadcast channel. All 20 pods subscribe to this channel and immediately evict the invalidated key from their local Caffeine instances.

4. What is the Autoscaling “Flapping” (Thrashing) effect, and how do you protect against it?

Answer: Flapping occurs when workload oscillates around the autoscaling threshold: CPU spikes to 85% $\to$ HPA adds 5 new pods $\to$ average CPU instantly drops to 40% $\to$ HPA terminates the 5 pods $\to$ CPU surges back to 85%. This continuous cycle of rapid pod scheduling and termination creates severe CPU thrashing (JVM JIT compilation churn) and overwhelms the Kubernetes API server. Remedy: Configure the behavior.scaleDown.stabilizationWindowSeconds parameter (typically 300 seconds / 5 minutes). When the load subsides, the HPA controller monitors metrics for 5 continuous minutes of sustained low traffic before executing pod terminations, smoothing out transient traffic dips.


🎯 Interview Cheat Sheet

Must Know

  • Definition: Adding identical stateless instances behind a load balancer to increase capacity and availability.
  • The Stateless Axiom: Absolutely zero session state or file storage in JVM memory or local disk; offload to Redis Cluster and Amazon S3.
  • Autoscaling Engines: Kubernetes HPA v2 for HTTP/CPU/RPS metrics; KEDA for event-driven message queue lag (Kafka/RabbitMQ).
  • Database Hazard: Always deploy a database connection pooler (PgBouncer / AWS RDS Proxy) to prevent pod fleets from exhausting PostgreSQL max_connections.
  • Java Trap: Avoid autoscaling on JVM Heap Memory (JVM does not immediately release memory back to the OS).
  • Shutdown Protocol: Enforce Graceful Shutdown (server.shutdown=graceful) with a preStop delay.

Scale-Out Readiness Checklist

  1. Application is 100% stateless (JWTs or Redis for sessions).
  2. Background cron tasks are locked via ShedLock to prevent duplicate execution across replicas.
  3. Database connection pool is capped, fronted by a connection proxy.
  4. Liveness, Readiness probes, and Graceful Shutdown hooks are configured.
  5. Autoscaling scale-down includes a stabilization window (stabilizationWindowSeconds: 300).

Common Follow-up Questions

  • “How do you handle sticky sessions?” $\to$ Sticky sessions are an anti-pattern in cloud microservices; sessions should be stored in Redis or encoded in signed JWTs.
  • “What is KEDA?” $\to$ Kubernetes Event-driven Autoscaling that scales pods based on message queue depth (Kafka lag) rather than CPU/memory.
  • “How does Karpenter interact with HPA?” $\to$ HPA scales pods; when cluster nodes are full, Karpenter provisions new physical/VM nodes to host the new pods.

Red Flags (DO NOT Say)

  • ❌ “Horizontal scaling means upgrading the server from 16 GB to 64 GB of RAM.” (That is Vertical Scaling; Horizontal Scaling adds more independent instances).
  • ❌ “We scale our Spring Boot microservices based on JVM Memory percentage in HPA.” (JVM retains heap memory; HPA will freeze at maximum pods).
  • ❌ “Each of our 50 pods opens 50 direct connections to PostgreSQL.” (Exhausts database connection limits; poolers like PgBouncer are required).