Chapter 5 · Part II: Modern Architecture Patterns and Event-Driven Systems
Microservice Communication and Resilience
A partial failure becomes a total outage through retries, shared pools, and unbounded queues. This chapter covers the mesh datapath that carries service traffic, the patterns that keep a failure contained, the deployment machinery that limits a bad release, and the flow control that stops overload from turning into collapse.
In this chapter
5.1 Service Mesh Datapaths
Every service call needs a timeout, a retry policy, a circuit breaker, a load balancing decision, mutual authentication, and a trace span. That is a few hundred lines of subtle code, and it must exist in every service in every language the organization uses.
A service mesh moves that code into a proxy beside or beneath the application. The application makes a plain call to a local address, and the proxy performs discovery, encryption, retry, and telemetry. Policy lives in a control plane, and a change applies with no application deployment.
Envoy's configuration model
Envoy is the proxy in Istio, in most gateway products, and in many custom platforms. Five objects describe its behavior, and understanding them explains every mesh built on it.
A listener binds an address and a port. A filter chain attaches to a listener and processes connections. A route table maps a request to a cluster. A cluster is a named group of endpoints with a load balancing policy and a connection pool. An endpoint is one address with health state and load metadata.
Filters compose. Network filters operate on bytes. The HTTP connection manager is itself a network filter that parses HTTP and then runs a chain of HTTP filters: authentication, rate limiting, header manipulation, fault injection, and finally the router. A WebAssembly or Lua filter inserts custom logic with no rebuild.
The xDS protocol delivers configuration. The control plane streams listener, route, cluster, and endpoint discovery responses over gRPC, and Envoy applies them without restarting. Aggregated discovery keeps updates in a consistent order, which matters because applying a route before its cluster exists creates a window where requests fail.
Envoy uses one thread per core with no shared state on the data path. Each worker owns its listeners, connections, and statistics, and configuration updates arrive as a message to each worker. That avoids lock contention, and it means connection counts and load balancing state are per worker rather than per process.
Sidecar, ambient, and the proxy tax
The sidecar model puts one proxy container in every pod. It gives the strongest isolation, because each proxy holds one workload's identity and configuration, and it costs a proxy per pod.
The cost is measurable and it drove the architecture change. Each Envoy sidecar holds 50 to 150 mebibytes, mostly configuration for every service it can reach. It adds roughly 0.5 to 2 milliseconds at the median and more at the tail, because a request crosses two extra proxies. It couples lifecycle: the sidecar must start before the application and stop after it, which Kubernetes only handled properly after native sidecar support arrived.
Ambient mode splits the proxy into two layers. A per-node ztunnel handles
layer 4 for every pod on the node: mutual TLS, identity, and layer 4
authorization. An optional per-namespace or per-service waypoint proxy, which
is a full Envoy, handles layer 7. Istio marked ambient mode stable in version
1.24 in November 2024.
SIDECAR
pod A pod B
+--------+ +--------+ +--------+ +--------+
| app |->| Envoy |===== mTLS ==>| Envoy |->| app |
+--------+ +--------+ +--------+ +--------+
localhost iptables REDIRECT iptables
two full proxies per call, one identity per pod
AMBIENT, layer 4 only
node 1 node 2
+--------+ +-----------+ +-----------+ +--------+
| app A |---->| ztunnel |==HBONE=| ztunnel |---->| app B |
+--------+ +-----------+ +-----------+ +--------+
eBPF or iptables redirect, one shared proxy per node
mTLS and L4 policy only, identities of every pod on the node
AMBIENT with a waypoint, when layer 7 is needed
app A -> ztunnel ==> waypoint (Envoy, per namespace) ==> ztunnel -> app B
pay the layer 7 cost only for the services that need it
Figure 5.1 Sidecar and ambient datapaths for one call.
Table 5.1: mesh data plane models compared
| Model | Memory per workload | Added p50 | Added p99 | Layer 7 | Identity blast radius |
|---|---|---|---|---|---|
| No mesh, library | 0 | 0 | 0 | whatever the library has | in process |
| Envoy sidecar | 50 to 150 MiB | 0.5 to 2 ms | 2 to 8 ms | full | one pod |
| Linkerd micro-proxy | 10 to 30 MiB | 0.2 to 0.8 ms | 1 to 4 ms | HTTP and gRPC | one pod |
Ambient, ztunnel only |
3 to 15 MiB per node | 0.2 to 0.6 ms | 1 to 3 ms | none | every pod on the node |
| Ambient plus waypoint | one Envoy per namespace | 0.6 to 2 ms | 2 to 8 ms | full | one namespace |
| eBPF datapath, no proxy | under 5 MiB per node | 0.05 to 0.3 ms | 0.5 to 2 ms | limited | every pod on the node |
Reported figures put ambient memory savings around 70 percent with comparable
or better latency. A common deployment routes roughly 80 percent of services
through ztunnel alone and adds a waypoint only where header-based routing or
per-request authorization is genuinely needed.
The identity blast radius column is the trade the table exists to show. A per-node proxy holds the identities of every workload on that node, so a compromise is broader than a compromise of one sidecar. For most organizations the resource saving wins, and for a hard multi-tenant boundary the sidecar still has an argument.
eBPF socket redirection
For two pods on the same node, the full network path is unnecessary. An eBPF
program attached at the socket layer rewrites the destination at connect
time and splices the two sockets together, so bytes move directly between
socket buffers with no loopback traversal, no packet processing, and no
encapsulation.
The saving is a few tens of microseconds per call and a measurable CPU
reduction on a node with dense service-to-service traffic. Cilium implements
this with sockops and sk_msg programs, and it applies to ambient meshes
and to sidecar meshes alike.
The limit is that eBPF cannot replace a layer 7 proxy. The kernel can redirect sockets, enforce layer 4 policy, and do service load balancing with a hash map lookup. It cannot parse a header, retry a request, or split traffic by percentage, because those need a full protocol parser and buffering. Every practical design uses eBPF for the transport and a proxy for layer 7.
Workload identity with SPIFFE and SPIRE
A service that authenticates with a shared secret has three problems. The secret is long-lived, it gets copied into configuration, and a leak grants indefinite access.
SPIFFE defines a naming scheme and a document format. A SPIFFE ID is a URI
such as spiffe://example.com/ns/payments/sa/charge-service. A SPIFFE
Verifiable Identity Document, or SVID, carries that identity in an X.509
certificate or a JWT. SPIRE is the reference implementation, and both are CNCF
projects.
Attestation is the part that makes it work. A SPIRE agent on each node proves the node's identity to the server through a platform mechanism such as a cloud instance identity document or a TPM. It then attests each workload locally by inspecting the process: its namespace, its service account, its container image digest, its parent process. Identity therefore derives from what the workload is rather than from a secret it holds.
SVIDs are short-lived. A one-hour certificate rotated at roughly the half-life is the common configuration. A leaked credential expires within the hour with no revocation infrastructure, which collapses the blast radius from indefinite to bounded. That property is the whole argument for the approach.
Mutual TLS then gives authentication and encryption in one step, and the authorization policy matches on the SPIFFE ID rather than on an IP address. That last point matters more than it sounds: in a cluster where addresses are recycled every few minutes, an IP-based policy is a policy that means nothing.
5.2 Resilience Patterns and Fault Tolerance
A resilient system keeps a partial failure partial. The patterns in this section each stop one specific path by which a local failure becomes a global one.
Timeouts and deadlines
A timeout bounds how long a caller waits. Every call needs one, and the value must come from the latency distribution rather than from a round number. Set it near the p99.9 of the healthy distribution. A timeout at the p50 fails half the healthy requests. A timeout at ten seconds holds a connection and a thread for ten seconds during an incident, which is how a caller runs out of capacity.
Deadlines beat timeouts in a call chain. A deadline is an absolute time that propagates, so a 300 millisecond budget at the edge becomes 280 milliseconds at the second hop and 210 at the third. A service that receives an expired deadline returns immediately instead of doing work nobody will read. gRPC propagates deadlines natively, and an HTTP system carries one in a header.
Retries and the amplification they cause
Retries are where systems fail in a way that looks like an attack. Consider a four-hop chain where each hop retries three times. One user request becomes up to 81 requests at the deepest service. A service that slows down receives 81 times its normal load, which makes it slower, which triggers more retries.
Four rules stop it. Retry at one layer only, usually the layer closest to the user. Use a retry budget that caps retries at 10 to 20 percent of total requests and drops them above that. Use exponential backoff with full jitter, because without jitter retries synchronize and arrive in waves. And retry only idempotent operations, on errors a retry can fix.
# Full jitter backoff inside a shrinking deadline. The random range
# is what breaks synchronization between clients.
import random, time
BASE_MS, CAP_MS, MAX_ATTEMPTS = 20, 2000, 4
def call_with_retry(fn, deadline_ms, budget):
start = time.monotonic()
for attempt in range(MAX_ATTEMPTS):
remaining = deadline_ms - (time.monotonic() - start) * 1000
if remaining <= 0:
raise DeadlineExceeded()
try:
return fn(timeout_ms=remaining)
except RetryableError:
if attempt == MAX_ATTEMPTS - 1 or not budget.try_consume():
raise # budget exhausted: fail fast
ceiling = min(CAP_MS, BASE_MS * (2 ** attempt))
sleep_ms = random.uniform(0, ceiling) # full jitter
if sleep_ms > remaining:
raise DeadlineExceeded()
time.sleep(sleep_ms / 1000.0)
Retries make a brownout worse, always
A service failing because it is overloaded gets more load from retries. A service failing because of a bug returns the same error to the retry. The only case where a retry helps is a transient fault affecting one attempt: a dropped packet, one unhealthy endpoint, a brief leader election. Detect the difference with a circuit breaker, and make the retry budget a hard cap rather than a suggestion.
Adaptive circuit breakers
A circuit breaker stops calls to a failing dependency so that the caller fails fast instead of queueing. Three states: closed passes traffic and counts failures, open rejects immediately for a cooldown, half-open lets a few probes through and returns to closed on success or to open on failure.
The value is not that the caller fails faster, although it does. The value is that the failing service stops receiving load and gets a chance to recover. Without a breaker, a service that fell over stays over, because the load never drops.
A fixed threshold, such as "open after 5 consecutive failures", behaves badly across traffic levels. Five failures out of five requests is a total outage. Five failures out of fifty thousand is noise. A sliding window over a count or a duration, with a minimum request volume before the breaker can trip, handles both. Typical parameters are a 10 second window, a minimum of 20 requests, and a 50 percent failure ratio.
Outlier detection is the finer-grained version, and it belongs in the load balancer rather than in the caller. Envoy ejects an individual endpoint after consecutive failures and returns it after a cooldown that grows with each ejection. One bad pod gets removed while the rest of the service keeps serving, which a service-level breaker cannot express.
Adaptive concurrency limits go further and remove the threshold entirely. The system measures round-trip latency and throughput, applies Little's law, and adjusts the in-flight limit toward the point where latency starts rising. The advantage over a fixed limit is that the correct limit changes with the request mix and with the hardware, so nobody has to guess it.
Bulkheads
A bulkhead limits how much of a resource one dependency can consume. Give each downstream dependency its own connection pool and its own concurrency limit. When one dependency hangs, it exhausts its own pool and nothing else.
Without bulkheads, one slow dependency consumes every thread in a shared pool and the service stops answering requests that do not touch it at all. This is the single most common way that an unimportant dependency takes down an important path.
The sizing rule comes from Little's law. A dependency called 200 times per second with a 50 millisecond p99 needs 10 concurrent slots at steady state. Give it 20 and cap it there. The cap is what converts a hang into a fast rejection on that path only.
Client-side load balancing
Balancing in the client removes a hop and removes a shared bottleneck. The client resolves every backend address and picks one per request.
Round robin distributes evenly and ignores that backends differ. Least request picks the endpoint with the fewest in-flight requests, which adapts to slow backends. Peak exponentially weighted moving average, which Linkerd uses, tracks a decaying latency estimate per endpoint and reacts faster than any count-based policy.
Power of two random choices is the algorithm worth knowing by name. Pick two endpoints uniformly at random and send to the less loaded of the two. It needs no global state, it costs two lookups, and it reduces the maximum load from order log n over log log n down to order log log n. That is an exponential improvement over random choice, achieved by sampling one extra endpoint.
100 backends, 100 requests, maximum queue depth at any backend
RANDOM pick 1 at random max depth ~ 5
O(log n / log log n)
P2C pick 2, send to lesser max depth ~ 2
O(log log n)
FULL LEAST-REQ scan all 100 max depth ~ 1
O(n) per decision, needs global state
P2C gets almost all of the benefit of a full scan for the cost of
two random lookups and no coordination. It also degrades gracefully:
stale load information hurts full least-request far more, because
every client stampedes toward the same "least loaded" backend.
Figure 5.2 Power of two random choices against the alternatives.
The stampede effect in that last line is the practical reason to prefer P2C. With a global least-request view that lags, every client sees the same idle backend and sends to it simultaneously. With P2C, each client samples independently, so the load spreads even when the information is stale.
Subsetting solves the other scaling problem. With 10,000 clients and 1,000 backends, every client holding a connection to every backend means ten million connections. Deterministic subsetting gives each client a subset of say 20 backends, chosen so that the subsets overlap evenly across the fleet. The connection count falls by a factor of 50 and the load stays balanced.
5.3 Traffic Management and Canary Deployments
A release is the largest single source of production incidents. Traffic management limits what a bad release can reach.
Deployment strategies
Blue-green keeps two full environments. Traffic switches from blue to green in one step, and rollback switches back. The switch is fast and complete, and it costs double the capacity, and it gives no signal before full exposure.
Rolling replaces instances a few at a time. It needs no extra capacity and it exposes a growing fraction of traffic with each step. Rollback takes as long as the roll forward, which is the property that hurts during an incident.
Canary sends a small percentage to the new version, watches the metrics, and increases. It gives the earliest signal at the lowest exposure. It needs a router that splits by weight, a metric comparison, and a decision rule.
step weight duration gate ---- ------ -------- ---------------------------------------- 0 1% 5 min error ratio, p99 latency, saturation 1 5% 5 min same, plus a business metric 2 15% 10 min same 3 35% 10 min same 4 75% 10 min same 5 100% -- promote, keep the old version for 30 min At each gate, compare canary against baseline, not against a static threshold. A platform-wide latency rise affects both, and comparing to a threshold would roll back a healthy release. Roll back on: error ratio above baseline by a margin, p99 above baseline by a margin, any crash loop, or a gate that cannot be evaluated because the canary took too little traffic.
Figure 5.3 A canary progression with automated analysis.
The comparison rule is the part teams get wrong. Compare the canary against a baseline running the old version at the same moment, not against a fixed threshold and not against yesterday. A platform-wide latency increase moves both, and a threshold comparison rolls back a release that is fine.
The second failure is a canary that takes too little traffic to produce a significant signal. A 1 percent canary on a service handling 10 requests per second sees 0.1 requests per second, which reaches statistical significance somewhere around next week. Either raise the canary weight or lengthen the step, and if neither works, accept that canary analysis does not apply to that service and use a different control.
Header-based routing and request shadowing
Weight-based splitting is random. Header-based splitting is deterministic: a request carrying a specific header, cookie, or user identifier goes to the new version. That gives internal dogfooding, a beta cohort, and a consistent experience for one user rather than a coin flip per request.
The routing key must be sticky per user rather than per request, or a user sees version 1 and version 2 alternating, which breaks any flow with state. Hash a stable identifier and compare against the split point, rather than drawing a random number per request.
Request shadowing, also called mirroring, copies live traffic to the new version and discards the responses. The new version sees production traffic with no user risk and no user-visible latency, because the primary response returns independently.
The hazard is side effects. A mirrored request that writes to a database writes twice, sends a second email, or charges a card again. Mirror only to an environment with its own data store, or restrict mirroring to endpoints that are read-only, and make sure that outbound integrations are stubbed in the shadow environment.
Automated rollback
A rollback that needs a human takes as long as it takes a human to notice. The whole value of progressive delivery comes from automating the decision.
Three signal classes drive it. Technical signals are error ratio, latency percentiles, saturation, and restart count. Business signals are conversion, checkout completion, and revenue per session, which catch failures that return HTTP 200. And infrastructure signals are readiness probe failures and scheduling failures, which catch a release that cannot start at all.
The rollback must be faster than the roll forward, which rules out a rolling strategy as the rollback path. Keep the old version's replicas running until the soak period ends, and make rollback a routing weight change rather than a redeployment. That turns a five-minute rollback into a five-second one.
Fault injection is the only way to know that any of this works
A timeout, a retry budget, a circuit breaker, and a bulkhead are never exercised in normal operation, so they rot silently. Inject a 5 second delay into one dependency and observe whether the caller's timeout fires, whether the breaker opens, and whether the user sees a degraded page rather than an error. Run it on a schedule, in production, during a low-traffic window. A resilience configuration that has never been tested is a resilience configuration that does not work.
5.4 Backpressure and Flow Control
Backpressure is the signal that travels from a slow consumer to a fast producer, telling it to slow down. A system without backpressure converts overload into unbounded queue growth, and unbounded queue growth converts into memory exhaustion and then into total failure.
The Reactive Streams contract
Reactive Streams defines a four-interface protocol for asynchronous stream
processing with non-blocking backpressure. It became part of the Java standard
library as java.util.concurrent.Flow and it has equivalents in most
ecosystems.
The mechanism is demand signalling. A Subscriber receives a Subscription
and calls request(n) to ask for at most n elements. The Publisher sends no
more than the outstanding demand. The subscriber therefore controls the rate,
and the publisher never overwhelms it.
That inversion is the whole idea. A push model lets the producer set the rate and needs a buffer whose size nobody can choose correctly. A pull model costs a round trip per element. Demand signalling is a pull model with batching: ask for 256, receive up to 256, ask again when half are processed.
Credit-based flow control on the wire
The same idea appears at the transport level. HTTP/2 and gRPC apply flow
control at the connection level and at the stream level, with a window that
the receiver advertises and the sender consumes. A sender that exhausts the
window stops until a WINDOW_UPDATE arrives. QUIC does the same with
MAX_DATA and MAX_STREAM_DATA, as Section 1.1 described.
The window size decides throughput on a long path, and the bandwidth-delay
product sets the required value. The gRPC default of 64 kibibytes per stream
caps a 50 millisecond path at about 1.3 megabytes per second, whatever the
link can do. Raise initial_window_size and enable the dynamic window where
the implementation offers one.
The propagation chain
Backpressure works only if it propagates all the way to the source. A single unbounded buffer anywhere in the chain absorbs the signal and the producer never learns.
client
| TCP receive window closes
v
[ HTTP/2 stream window ] gRPC flow control
|
v
[ accept queue, bounded ] somaxconn
|
v
[ worker pool, bounded queue, 1000 slots ] <-- rejects when full
|
v
[ per-dependency semaphore, 20 slots ] <-- bulkhead
|
v
[ connection pool to the database, 20 conns ]
|
v
database
Replace ANY bounded queue with an unbounded one and the chain breaks
there. Memory grows until the process dies, and every item in the
queue is already past its deadline by the time it is served.
Figure 5.4 Backpressure propagating, and the one place it breaks.
Every layer has a mechanism, and the failure is always the same shape: one unbounded queue. An unbounded work queue in a thread pool, a channel with no capacity limit, a client library buffer with no cap, or an in-memory list that accumulates while a downstream call retries.
Bound every queue, and prefer dropping to buffering
Give every queue a maximum size and a policy for a full queue: block the producer, reject the item, or drop the oldest. Blocking propagates the backpressure, which is usually correct for a request path. Dropping the oldest is correct for telemetry and for any data whose value decays. Never grow without bound. Export queue depth and rejection count as metrics, and alert on the rejection rate rather than on the depth, because depth is normal and rejection is not.
Load shedding and graceful degradation
When a server is beyond capacity, the correct action is to reject some requests quickly rather than to queue them all slowly. Queueing delay at 99 percent utilization is ninety-nine service times, so a queued request is served long after the client gave up, and the work is pure waste.
Shed on a measured signal. Queue depth is the most direct. The ratio of measured latency to the target latency works well and adapts automatically. CPU saturation works for compute-bound services and lags for I/O-bound ones. Whatever the signal, shed the lowest-priority traffic first, which requires requests to carry a priority.
Priority classes make degradation graceful rather than random. A checkout request outranks a recommendation request, which outranks a prefetch, which outranks a background analytics call. Under pressure the system drops them in that order, so the user still buys the item while the recommendations disappear. Without classes, shedding removes 30 percent of everything, including 30 percent of checkouts.
Controlled Delay, or CoDel, is a shedding discipline worth knowing. It measures the time an item spends in the queue rather than the queue length. When the minimum queueing delay over a window exceeds a target, it starts dropping. The advantage is that it adapts to any service rate with no tuning and it is resistant to the bufferbloat problem that a length-based threshold has.
Finally, the source deserves a control of its own. Shedding at the server still costs a connection, a parse, and a response. A client that knows the server is shedding does better by not sending: a token bucket at the client, refilled by the server's response headers, removes the load before it becomes traffic. That is what an adaptive client-side limiter does, and it is the only control that reduces work on both sides.
Sources and further reading
The Envoy documentation on the configuration model and on xDS. The Istio ambient mesh overview and its performance benchmarks. The Cilium documentation on socket-level redirection. The SPIFFE and SPIRE documentation. Michael Nygard, "Release It!", second edition, for the stability patterns. Michael Mitzenmacher, "The Power of Two Choices in Randomized Load Balancing", 2001. Marc Brooker's writing on retries, backoff, and jitter at the AWS Builders' Library. The Reactive Streams specification. Kathleen Nichols and Van Jacobson, "Controlling Queue Delay". Netflix's concurrency-limits library.
What this chapter established
- Envoy's model is listeners, filter chains, routes, clusters, and endpoints, delivered by xDS and applied without restart. Each worker owns its own state.
- A sidecar costs 50 to 150 mebibytes and 0.5 to 2 milliseconds per hop. Ambient mode moves layer 4 to a per-node proxy and reports roughly 70 percent memory savings.
- A per-node proxy holds the identities of every workload on the node, which widens the blast radius of a compromise.
- eBPF can redirect sockets and enforce layer 4 policy. It cannot parse a header or retry a request, so layer 7 still needs a proxy.
- SPIFFE identity derives from what a workload is, attested by the platform. One-hour SVIDs bound a leaked credential without revocation infrastructure.
- In a cluster that recycles addresses every few minutes, an IP-based authorization policy means nothing.
- Set timeouts from the p99.9 of the healthy distribution, and prefer a propagated deadline over a per-hop timeout.
- Three retries at four hops turn one request into eighty-one. Retry at one layer, with a budget and with full jitter.
- A circuit breaker's value is that it removes load from a failing service, not that the caller fails faster. Use a sliding window with a minimum request volume, not a consecutive-failure count.
- A bulkhead gives each dependency its own pool, so one hanging dependency cannot consume every thread.
- Power of two random choices reduces maximum load from order log n over log log n to order log log n, with no global state and no stampede when information is stale.
- Compare a canary against a concurrent baseline, not against a static threshold. A platform-wide latency rise moves both.
- Mirrored traffic writes twice. Mirror only into an environment with its own data and stubbed outbound integrations.
- Make rollback a routing weight change rather than a redeployment, so that it is faster than the roll forward.
- Demand signalling inverts control: the subscriber requests n elements and the publisher never exceeds outstanding demand.
- Backpressure propagates only if every queue in the chain is bounded. One unbounded buffer absorbs the signal and the producer never learns.
- Shed by priority class, so that degradation removes recommendations before it removes checkouts.