AYOUB.*
← ENTERPRISE DISTRIBUTED ARCHITECTUREREPOS ↗

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
  1. 5.1 Service Mesh Datapaths
  2. 5.2 Resilience Patterns and Fault Tolerance
  3. 5.3 Traffic Management and Canary Deployments
  4. 5.4 Backpressure and Flow Control
  5. Sources and further reading

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.

Chapter 5 of Enterprise Distributed Architecture and Cloud-Native Platforms, revision 1.0. Attribution and sole author credit: Ayoub Zulfiqar.