bulkhead-load-shedding
Bulkhead Isolation & Load Shedding
The Problem: One bad apple spoils the bunch
When a microservice begins to slow down (e.g., due to a database lock, high CPU, or network latency), the immediate effect is that HTTP requests back up. In a naive API Gateway, these slow requests consume threads and connection pool resources. If the gateway exhausts its connection pool waiting for account-service, it will be unable to serve requests for transfer-service—even if transfer-service is perfectly healthy. One failing downstream brings down the entire edge.
Furthermore, queuing unbounded amounts of work in front of a struggling service only makes the problem worse (the "thundering herd" effect).
The Pattern
- Bulkhead Isolation: Name derived from ship hulls. If one compartment floods, the ship shouldn't sink. In software, this means allocating strict concurrency limits per downstream service. If
account-serviceis allowed 100 concurrent requests, the 101st request is immediately rejected, preserving gateway resources for other services. - Load Shedding: When the bulkhead is full, it is better to return a fast
503 Service Unavailablethan to hold the connection and timeout later. Rejecting work early sheds load from the struggling system, giving it breathing room to recover.
Where it lives in our code
- The Gateway (Edge): In
gateway/src/Gateway/Resilience/DownstreamResilience.cs, we use Polly v8'sAddConcurrencyLimiter.- We configure it with a
PermitLimit(e.g., 100) and aQueueLimit(e.g., 50). - It wraps the
AddCircuitBreakerandAddTimeoutin a pipeline (AddResilienceHandler). - When the
RateLimiterRejectedExceptionis thrown, our custom middleware catches it and returns a503 Service Unavailableindicating "Downstream is overloaded".
- We configure it with a
- The Message Broker (Async): In
account-service'sProgram.cs, we sete.PrefetchCount = 10ande.ConcurrentMessageLimit = 5.- The
PrefetchCountacts as a network-level backpressure mechanism. The consumer won't pull more than 10 messages into memory from RabbitMQ. - The
ConcurrentMessageLimitacts as an async bulkhead, ensuring the consumer only uses 5 worker threads, preventing thread starvation in the host process.
- The
Trade-offs
- Rejection vs. Wait: Load shedding forces the client to handle the failure. The client must either retry (with backoff) or fail gracefully. It trades individual request success for overall system survival.
- Tuning Limits: Setting the concurrency limit too low causes unnecessary rejections during normal bursts. Setting it too high defeats the purpose of the bulkhead. These limits must be tuned based on real-world load testing.
Interview Q&A
Q: What is the difference between a Rate Limiter and a Bulkhead (Concurrency Limiter)? A Rate Limiter restricts requests over time (e.g., 100 req/sec per user) to prevent abuse. A Bulkhead restricts concurrent in-flight requests (e.g., max 50 active threads) to protect internal capacity and isolate failures.
Q: Why put the Bulkhead before the Circuit Breaker in the pipeline? The pipeline executes from the outside in. If the Bulkhead is on the outside, it can reject requests immediately without ever interacting with the Circuit Breaker's state machine, keeping overhead extremely low.
Try to break it
See chaos-drills.md for the Slow DB Injection drill, which proves the Bulkhead protects the gateway.