AYOUB.*
← CS FUNDAMENTALSREPOS ↗

Chapter 11 · Part III: Cloud Native, Infrastructure, and Network Engineering

Enterprise Microservices and Mesh Architectures

Once a system has fifty services, every one of them needs mutual TLS, retries, timeouts, and telemetry. A service mesh moves that code out of the application and into the network. This chapter covers the Envoy datapath, the sidecar and ambient models, and the resilience patterns that stop one slow service from taking down the rest.

In this chapter
  1. The problem a mesh solves
  2. Envoy: the datapath that almost everyone uses
  3. Sidecar, ambient, and the cost of the proxy
  4. Timeouts, retries, and the storms they cause
  5. Circuit breakers, bulkheads, and load shedding
  6. Backpressure end to end
  7. Traffic management
  8. What a mesh costs, and when to skip it
  9. Sources and further reading

The problem a mesh solves

A microservice calls other services. Each call needs a timeout, a retry policy with backoff, a circuit breaker, a load balancing decision, mutual authentication, and a trace span. That is a few hundred lines of correct, subtle code, and it must exist in every service in every language that the organization uses.

Library-based solutions put that code in a shared dependency. Netflix Hystrix and Ribbon did this for the JVM, and gRPC does a smaller version of it today. The approach works and it has three costs. It exists once per language. An upgrade requires every team to rebuild and redeploy. A team that skips the upgrade keeps the old behavior with no way to see it centrally.

A service mesh moves the code into a proxy that sits beside or beneath the application. The application makes a plain call to a local address, and the proxy performs the discovery, the encryption, the retry, and the telemetry. The policy lives in a control plane, and a change applies without touching application code.

  CONTROL PLANE  (istiod, linkerd-destination, cilium-operator)
    service discovery, certificate issuance, policy compilation
              |  xDS / gRPC stream, pushes configuration
              v
  +-----------------------+            +-----------------------+
  |  pod A                |            |  pod B                |
  |  +--------+  +------+ |   mTLS     | +------+  +--------+  |
  |  |  app   |->| proxy|-|===========>|-|proxy |->|  app   |  |
  |  +--------+  +------+ |  HTTP/2    | +------+  +--------+  |
  |   localhost   iptables|            |                       |
  +-----------------------+            +-----------------------+

  The app dials a plain address. An iptables or eBPF redirect sends the
  connection to the local proxy. The proxy resolves the destination,
  picks an endpoint, opens an mTLS connection, and records a span.

Figure 11.1 Control plane, data plane, and the path of one call.

Envoy: the datapath that almost everyone uses

Envoy is the proxy in Istio, in most gateway products, and in many custom platforms. Its configuration model has five objects, and understanding them explains the behavior of 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 do the work, and they compose. Network filters operate on bytes. The HTTP connection manager is 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 filter or a Lua filter inserts custom logic without rebuilding the binary.

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 the updates in a consistent order, which matters because applying a route before its cluster exists produces a gap where requests fail.

Envoy's threading model is a thread per core with no shared state on the data path. Each worker owns its own listeners, connections, and statistics, and configuration updates arrive as a message to each worker. That design avoids the lock contention that Chapter 4 described, and it means that connection counts and load balancing state are per worker rather than per process.

Least request across workers is not least request across the process

Envoy's default load balancing policy is least request, which picks the endpoint with the fewest active requests among a small random sample. Because each worker keeps its own counts, the sample sees one worker's view. With many workers and few endpoints, the effect is closer to random than to least request. Peak EWMA, which Linkerd uses, tracks a decaying latency estimate per endpoint and reacts faster to a slow backend than any count-based policy does.

Sidecar, ambient, and the cost of the proxy

The sidecar model puts one proxy container in every pod. It gives the strongest isolation, because each proxy holds only one workload's identity and configuration. It costs a proxy per pod.

The cost is measurable and it is the reason the model changed. Each Envoy sidecar holds 50 to 150 mebibytes of memory, mostly configuration for every service that it can reach. It adds roughly 0.5 to 2 milliseconds at the median and more at the tail, because the request crosses two extra proxies. It also 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: mutual TLS, identity, and layer 4 authorization, for every pod on the node. An optional per-namespace or per-service waypoint proxy, which is a full Envoy, handles layer 7: HTTP routing, retries, and fine-grained authorization. Istio marked ambient mode stable in version 1.24 in November 2024.

The economics change because most services need only the layer 4 part. Reported figures put memory savings around 70 percent with comparable or better latency. A common deployment pattern routes roughly 80 percent of services through ztunnel alone. It adds a waypoint only for the services that need header-based routing or per-request authorization.

Table 11.1: mesh data plane models compared

Model Memory per workload Added p50 Added p99 Layer 7 features Isolation
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 per pod
Linkerd micro-proxy 10 to 30 MiB 0.2 to 0.8 ms 1 to 4 ms HTTP and gRPC per pod
Istio ambient, ztunnel only 3 to 15 MiB per node 0.2 to 0.6 ms 1 to 3 ms none, layer 4 only per node
Istio ambient with waypoint plus one Envoy per namespace 0.6 to 2 ms 2 to 8 ms full per namespace
eBPF datapath, Cilium under 5 MiB per node 0.05 to 0.3 ms 0.5 to 2 ms limited without a proxy per node

The isolation column is the trade that the table exists to show. A per-node proxy holds the identities of every workload on that node. A compromise of ztunnel 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.

Timeouts, retries, and the storms they cause

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 the timeout 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.

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 whose result nobody will read. gRPC propagates deadlines natively, and an HTTP system can carry one in a header.

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. That is a retry storm, and it turns a degradation into an outage.

Four rules stop it.

  1. Retry at one layer only. Usually that is the edge, or the layer closest to the user.
  2. Use a retry budget. Allow retries to be at most 10 to 20 percent of total requests, and drop them above that.
  3. Use exponential backoff with full jitter. Without jitter, retries synchronize and arrive in waves.
  4. Retry only idempotent operations, and only on errors that a retry can fix.
# Full jitter backoff. The random range is what breaks synchronization.
import random, time

BASE_MS, CAP_MS, MAX_ATTEMPTS = 20, 2000, 4

def call_with_retry(fn, deadline_ms):
    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:
                raise
            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 that is failing because it is overloaded gets more load from retries. A service that is failing because of a bug returns the same error to the retry. The only case where a retry helps is a transient fault that affects one attempt: a dropped packet, a single 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.

Circuit breakers, bulkheads, and load shedding

A circuit breaker stops calls to a failing dependency so that the caller fails fast instead of queueing. It has three states. Closed passes traffic and counts failures. Open rejects immediately for a cooldown period. Half-open lets a few probe requests 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.

Envoy's version of this is outlier detection, which ejects an individual endpoint from the load balancing pool after consecutive failures and returns it after a cooldown that grows with each ejection. It is finer grained than a service-level breaker, because one bad pod gets removed while the rest of the service keeps serving.

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.

Load shedding is the last line. When a server is beyond its capacity, the correct action is to reject some requests quickly rather than to queue them all slowly. Chapter 4 gave the reason: queueing delay at 99 percent utilization is ninety-nine service times, so a queued request arrives long after the client gave up. Shed based on a measured signal such as queue depth, CPU, or the ratio of measured latency to target latency, and shed the lowest priority traffic first.

Adaptive concurrency limits do this automatically. The system measures round trip latency and throughput, applies Little's law from Chapter 4, and adjusts the in-flight limit toward the point where latency starts rising. Netflix published a library that does this with a TCP-congestion-control-like algorithm, and Envoy ships an adaptive concurrency filter. The advantage over a fixed limit is that the correct limit changes with the request mix and with the hardware.

Backpressure end to end

Backpressure is the signal that flows backward 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.

Every layer has a mechanism. TCP has the receive window. HTTP/2 and gRPC have per-stream and per-connection flow control windows. A message broker has consumer lag and, in some designs, a producer quota. An application has a bounded queue that blocks or rejects when full.

The failure is always the same shape: one unbounded queue somewhere in the chain. An unbounded asyncio.Queue, a thread pool with an unbounded work queue, a channel with no capacity limit, or a buffer in a client library. The queue absorbs the overload until memory runs out, and by then every item in it is older than its deadline.

Bound every queue, and prefer dropping to buffering

Give every queue a maximum size and a policy for what happens when it fills: 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. Add a metric for queue depth and for rejected items, and alert on the rejection rate rather than on the depth, because depth is normal and rejection is not.

Controlled Delay, or CoDel, is a queue discipline that 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 that Chapter 9 described. Envoy and several application frameworks implement it.

Traffic management

A canary release sends a small percentage of traffic to a new version, watches the metrics, and increases the percentage. The mesh does the splitting by weight at the route level, so no load balancer reconfiguration and no DNS change is involved. Progressive delivery tools such as Flagger and Argo Rollouts automate the ramp and the rollback based on metric thresholds.

Traffic mirroring copies requests to a new version and discards the responses. The new version sees production traffic with no user risk. The one hazard is side effects: a mirrored request that writes to a database writes twice. Mirror only to an environment with its own data, or only to endpoints that are read-only.

Fault injection adds artificial delays and errors so that the resilience configuration gets tested. Inject a 5 second delay into one dependency and see whether the caller's timeout fires, whether the circuit breaker opens, and whether the user sees a degraded page rather than an error. This is the only way to know that the configuration works, because the configuration is never exercised in normal operation.

What a mesh costs, and when to skip it

The mesh is not free. It adds a proxy to the data path, a control plane to operate, a certificate authority to manage, and a new failure mode where a control plane problem affects every service at once. It adds a layer to every debugging session, because a failure can now come from the application, the proxy, or the policy.

Skip it when the service count is small. Below roughly ten services, a well-chosen gRPC or HTTP client library with sensible defaults gives most of the benefit at none of the operational cost. Skip it when every service is in one language and one team owns them all, because a shared library then has no upgrade problem.

Adopt it for one of four reasons. The language count is above two. Mutual TLS between every pair of services is a compliance requirement. Uniform telemetry across services matters more than per-service tuning. Or traffic policy must change without a deployment.

Adopt it incrementally, whatever the reason. Turn on mutual TLS and telemetry first, because those are the highest value and the lowest risk. Add traffic policy per service afterwards. A team that enables every feature at once cannot tell which one caused the latency change.

Sources and further reading

The Envoy documentation on the configuration model and on xDS. The Istio ambient mesh overview and the performance benchmarks that accompany it. Michael Nygard, "Release It!", second edition, for the stability patterns. Marc Brooker's writing on retries, backoff, and jitter at the AWS Builders' Library. Netflix's concurrency-limits library and the adaptive concurrency literature. Kathleen Nichols and Van Jacobson, "Controlling Queue Delay", for CoDel.

What this chapter established

  • A mesh moves timeouts, retries, mutual TLS, and telemetry out of every application and into a proxy that a control plane configures.
  • Envoy's model is listeners, filter chains, routes, clusters, and endpoints, delivered by xDS and applied without a restart. Each worker thread owns its own state.
  • A sidecar costs 50 to 150 mebibytes and 0.5 to 2 milliseconds per hop. Ambient mode splits layer 4 into a per-node proxy and reports roughly 70 percent memory savings.
  • Set timeouts from the p99.9 of the healthy distribution, and prefer a propagated deadline over a per-hop timeout.
  • Retries multiply along a chain. 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.
  • A bulkhead gives each dependency its own pool, so that one hanging dependency cannot consume every thread.
  • Shedding load beats queueing it. At 99 percent utilization a queued request waits ninety-nine service times and the client has already gone.
  • Every unbounded queue is a future memory exhaustion. Bound them all and alert on rejection rate, not on depth.
  • Fault injection is the only way to know that the resilience configuration works, because normal operation never exercises it.
  • Below about ten services in one language, a good client library beats a mesh. Adopt a mesh for mutual TLS and telemetry first, and policy second.

Chapter 11 of Computer Science: From First Principles to Production Scale, revision 1.0. Attribution and sole author credit: Ayoub Zulfiqar.