Frontend Labs
⋯

57. Walkthrough: Design a Service Mesh

The prompt. "Design a service mesh for our company's microservices." Variants: "Design Istio", "Design the service-to-service communication layer", "How would you give every service mTLS, retries, and traffic splitting without changing app code?" This is a platform / infrastructure problem, common in Senior and Staff+ infrastructure loops at Google, Microsoft (Azure), and cloud and platform teams. The interviewer wants control-plane vs data-plane thinking, config-distribution at scale, failure isolation, and adoption.

Step 0 — Clarify the business goal (≈ 3 min)

🎤 Candidate: What's driving this? I can think of three different reasons: (1) security/compliance, zero-trust mTLS everywhere for an audit; (2) reliability, uniform timeouts, retries, and safe canary rollouts; or (3) developer productivity, taking networking boilerplate out of hundreds of codebases in several languages.

🧑‍💼 Interviewer: All three, but the trigger is a compliance requirement: all internal traffic must be encrypted and authenticated within 12 months.

🎤 Candidate: Then mTLS with workload identity is the must-have, and traffic management is the follow-on value. That also tells me the rollout plan matters as much as the architecture, because a mesh that breaks production during adoption gets abandoned. What's the environment?

🧑‍💼 Interviewer: About 3,000 services, 200k pods on Kubernetes across 4 regions, plus some legacy VMs. Polyglot: Java, Go, Python, Node.

🎤 Candidate: Polyglot rules out a library-only approach as the main answer, which points to a proxy-based data plane.

Step 1 — Functional requirements

In scope Out of scope
Automatic mTLS between workloads, with workload identity North-south API management (keep the edge gateway separate, but integrate)
Service discovery + client-side load balancing Application-level authZ logic (the mesh enforces service-to-service policy only)
Traffic policy: timeouts, retries, circuit breaking, outlier detection Replacing the CNI / network layer
Traffic shifting: canary %, header-based routing, mirroring
Authorization policy: "service A may call B's /payments/*"
Telemetry: golden metrics + trace propagation per hop
Works for VMs (legacy) as well as Kubernetes

Step 2 — Non-functional requirements

NFR Target
Added latency per hop p50 < 0.5 ms, p99 < 2 ms
Resource overhead < 10% of fleet CPU/memory (this is a cost requirement: 200k pods × a sidecar's memory adds up)
Config propagation Policy change visible on all affected proxies < 30 s (p99)
Control plane availability Data plane must keep working if the control plane is down (static stability)
Certificate lifetime Short (≤ 24 h), automatic rotation, no human in the loop
Blast radius A bad config must not take down every service at once (staged rollout)
Adoption Opt-in per namespace, permissive → strict mTLS migration, instant opt-out

Step 3 — Estimates

Pods: 200k; services: 3k; endpoints per service: avg ~70 (some 5,000+)
Sidecar memory: ~50-150 MB depending on config size → 200k × 100 MB = 20 TB RAM (!)
Sidecar CPU: ~0.1-0.5 vCPU at moderate RPS → tens of thousands of vCPUs fleet-wide
  → cost of the mesh itself is a top-10 infra line item → config scoping & sidecar-less options matter

Config size if every proxy knows every endpoint: 3k services × 70 endpoints ≈ 210k endpoints
  → each proxy holds all → ~tens of MB per proxy, and every endpoint change fans out to 200k proxies
  Pod churn: deploys + autoscaling ≈ 50 endpoint changes/s fleet-wide
  Naive push: 50 changes/s × 200k proxies = 10M updates/s ← impossible
  With scoping (a proxy only receives services it calls, avg ~20) + batching/debounce (100 ms-1 s)
  + incremental (delta) updates → manageable

Certificates: 200k workloads, 24 h certs renewed at ~50% lifetime → ~4.6 issuances/s average
  BUT: a region-wide restart of 50k pods in 10 min → ~85/s burst → issuance must handle bursts (§56)

🎤 Candidate: The estimates tell me the hard problems are config distribution fan-out and the resource cost of sidecars, not the request path itself.

Step 4 — APIs & data model

The user-facing API is declarative config (Kubernetes CRDs or a config API). The control plane's job is to compile intent into per-proxy config.

# Intent: canary 5% of traffic to v2 with a 2s timeout and safe retries
kind: TrafficPolicy
service: payments
routes:
  - match: {path_prefix: /v1/charge}
    destinations: [{subset: v1, weight: 95}, {subset: v2, weight: 5}]
    timeout: 2s
    retries: {attempts: 2, on: [connect-failure, unavailable], budget_percent: 10}
---
kind: AuthorizationPolicy
service: payments
allow:
  - from: spiffe://corp/ns/checkout/sa/checkout-api
    methods: [POST]
    paths: [/v1/charge]

Internal model (control plane): Service, Endpoint(ip, port, labels, locality, health), Policy(version), ProxyStatus(proxy_id, config_version_acked). The proxy protocol is xDS (Envoy's discovery APIs: LDS listeners, RDS routes, CDS clusters, EDS endpoints, SDS secrets), over long-lived gRPC streams with ACK/NACK per version.

Step 5 — High-level architecture

                         ┌──────────────────── CONTROL PLANE (per region, replicated) ────────────────────┐
  Platform/API ─────────▶│ Config API / CRDs ─▶ Validator (schema, policy lint, blast-radius checks)      │
  (GitOps)               │        │                                                                       │
  K8s API / VM registry ▶│ Discovery (watch endpoints)                                                   │
                         │        ▼                                                                       │
                         │ Config compiler: intent + topology → per-proxy snapshot (scoped)               │
                         │        ▼                                                                       │
                         │ xDS servers (horizontally scaled, sharded by proxy) ── debounce, delta push    │
                         │ CA / identity issuer (SPIFFE IDs) ◀── see §56                                  │
                         └───────────────┬────────────────────────────────────────────────────────────────┘
                                         │ gRPC streams (xDS + SDS)
          ┌──────────────────────────────┼──────────────────────────────────────┐
          ▼                              ▼                                      ▼
   ┌─────────────┐                ┌─────────────┐                        ┌─────────────┐
   │ Pod: app A  │  mTLS (HTTP/2) │ Pod: app B  │                        │ VM: legacy C│
   │  + sidecar  │═══════════════▶│  + sidecar  │                        │ + proxy     │
   │ (Envoy)     │                │ (Envoy)     │                        │ agent       │
   └─────────────┘                └─────────────┘                        └─────────────┘
   iptables/eBPF redirects app traffic into the local proxy (no app code change)
   Telemetry: proxies emit metrics + spans → collectors (§16)

Walk a request: app A calls http://payments/v1/charge → traffic is redirected to A's sidecar → the route table (RDS) matches the path → 95/5 weighted pick of subset v1/v2 → the endpoint (EDS) is chosen locality-first by least-request → mTLS handshake with B's sidecar (certificates from SDS; verify B's SPIFFE ID) → B's sidecar checks the AuthorizationPolicy (is A's identity allowed for POST /v1/charge?) → forwards to app B on localhost → both proxies record metrics and propagate trace headers.

Step 6 — Deep dives

Deep dive 1: config distribution at scale

 Endpoint change (pod Ready) ─▶ discovery watch ─▶ change queue
   ─▶ DEBOUNCE (collect changes for 100 ms; max wait 1 s)
   ─▶ compute affected proxies: only proxies whose scope includes service S (reverse index S → proxies)
   ─▶ for each: build DELTA (only changed EDS for S), version v+1
   ─▶ push over existing stream ─▶ proxy applies ─▶ ACK(v+1) | NACK(v+1, error) → keep v
   ─▶ status store: proxy → acked version  (dashboard "config converged to v+1: 99.7%")
  • Scoping is the main lever: by default each proxy only receives config for services it is declared, or observed, to call. That reduces memory, push fan-out, and CPU on both sides by one to two orders of magnitude. Istio's Sidecar resource does exactly this.
  • Delta xDS sends only changed resources, instead of full state of the world on every change.
  • Sharding the control plane: each xDS server holds streams for a subset of proxies (consistent hashing by proxy ID). Servers are stateless apart from caches, so on server failure proxies reconnect to another.
  • Push ordering: to avoid traffic black holes, clusters (CDS) must arrive before the routes (RDS) that reference them, and endpoints before routes shift weight onto a new subset (the "make before break" ordering in xDS).

Deep dive 2: identity and mTLS

  • Each workload gets a SPIFFE ID (spiffe://corp/ns/<ns>/sa/<serviceaccount>). The node agent proves the workload's identity (Kubernetes projected service-account token, or cloud instance identity for VMs) and receives a short-lived X.509 SVID. The private key is generated in the pod and never leaves it; only a CSR goes to the CA (details in §56).
  • Rotation: renew at ~50% of lifetime with jitter, delivered to the proxy over SDS without restarts or dropped connections.
  • Migration: run PERMISSIVE mode first (accept both plaintext and mTLS), measure the share of mTLS traffic per service, then switch to STRICT namespace by namespace. That's how you meet the 12-month compliance deadline without an outage.

Deep dive 3: sidecar vs sidecar-less (the cost and complexity decision)

Option How Pros Cons
Sidecar proxy per pod (classic Istio/Linkerd) Envoy (or Linkerd's Rust micro-proxy) next to every app Full L7 features per workload; strong isolation Memory/CPU per pod (20 TB in our estimate), startup ordering, upgrades mean restarting every pod
Per-node proxy (Istio ambient's ztunnel, eBPF-based meshes like Cilium) Shared L4 proxy per node for mTLS; optional L7 "waypoint" proxies per service Much lower overhead; mesh upgrades don't restart apps Shared failure domain per node; L7 features need an extra hop
Proxyless gRPC (gRPC with xDS support, e.g. Google Traffic Director) Library reads xDS directly No extra hop, lowest latency Only for gRPC languages and libraries; upgrades need app rebuilds

🎤 Candidate: Given that compliance (mTLS) is the trigger and cost is significant, I'd use node-level L4 mTLS for everyone, plus L7 proxies only where traffic management is needed (critical services, canaries). Proxyless gRPC is an option for the highest-QPS internal services. That's roughly a 5–10× cut in mesh overhead compared with a sidecar everywhere.

Deep dive 4: safety of the mesh itself

The mesh sits in every request path, which makes it the largest blast radius in the company.

  • Config validation before acceptance: schema, references, and policy lint ("this AuthorizationPolicy denies all traffic to payments: block unless --force").
  • Staged config rollout: new policy versions go to a canary scope (one namespace or region) first, with automatic rollback if error rates rise. The same for proxy version upgrades (canary revision, a few namespaces at a time).
  • Static stability: if the control plane is down, proxies keep serving with the last good config. Certificates have enough remaining lifetime (e.g. 24 h validity renewed at 12 h gives at least 12 h to restore the CA or control plane).
  • Retry budgets by default (§14): the mesh makes retries easy, so it must also prevent retry storms.

Step 7 — Failure modes, scale, cost, evolution

Failure What happens Design response
Control plane down No new config or endpoints; existing traffic flows Static stability; new pods can't get certs → alert; multi-replica, per-region control planes
Bad policy pushed Possibly every call to a service is denied Validation + staged rollout + one-click revert to the previous version
xDS push storm (mass deploy) Control plane CPU saturates, convergence slows Debounce, rate-limit pushes, scoping, priority for EDS over full recomputation
Sidecar crash That pod's traffic fails Liveness restart; the app sees connection errors and retries via other replicas
CA down Renewals fail; certs expire after their lifetime Long enough lifetime buffer, alerts on renewal failures at 25% of lifetime, multi-region CA (§56)

Cost: the mesh's own footprint (proxy CPU and memory) plus telemetry volume. Track "mesh cost per 1k RPS" as a platform KPI.

Evolution and org: a platform team owns the control plane and proxy versions. Service teams own their TrafficPolicy. Roll out in phases: (1) telemetry only, (2) permissive mTLS, (3) strict mTLS for compliance scope, (4) traffic management for opt-in teams. Adoption metric: % of traffic on mTLS.

Step 8 — Wrap-up

🎤 Candidate: The design is a regional, horizontally scaled control plane compiling declarative intent into scoped, delta-pushed xDS config, and an L4 node-level mTLS data plane with L7 proxies where they add value. Identity is SPIFFE certificates with 24-hour lifetimes. The main risks are the mesh becoming a global failure domain, which we handle with staged config and proxy rollouts and static stability, and its resource cost, which we handle with sidecar-less L4 and scoping. The rollout is permissive → strict per namespace to hit the compliance deadline safely.

Interviewer follow-ups & strong answers

Follow-up Strong answer
"Why not just use a library (like Finagle/Hystrix)?" It works for one language. With four languages you'd maintain four libraries with feature drift, and upgrades require every team to redeploy. A proxy centralizes behavior and upgrades. Libraries remain fine for proxyless gRPC hot paths
"Pod starts and the app makes calls before the sidecar is ready?" A startup race. Configure the app to start after the proxy is ready (Istio's holdApplicationUntilProxyStarts, or Kubernetes native sidecar containers, which start before app containers)
"Kubernetes Jobs never finish because the sidecar keeps running?" Native sidecar containers (Kubernetes 1.28+) are terminated after the main containers. Before that, the job had to call the proxy's quit endpoint
"How do you handle long-lived gRPC streams when endpoints change?" Existing streams stay on old endpoints, so set max connection age/duration to rebalance gradually, and drain gracefully on pod termination
"Cross-cluster or multi-region?" One control plane per region with shared trust roots; locality-aware failover (prefer same zone → region → remote); east-west gateways between clusters
"Debugging when something's wrong?" proxy-status (config version converged?), per-proxy config dump, access logs with response flags (e.g. upstream connect failure vs no healthy upstream), mesh golden-signal dashboards

🌍 Real-world references

  • Lyft created Envoy (open-sourced 2016) to fix their polyglot networking problems. It became the data plane for Istio and many cloud meshes, and xDS became a de facto standard API.
  • Google Traffic Director and proxyless gRPC: gRPC libraries consume xDS directly, giving mesh features without sidecars.
  • Istio ambient mode (announced 2022): splits the mesh into a per-node L4 ztunnel and optional per-service L7 waypoint proxies, directly motivated by sidecar cost and operational pain.
  • Linkerd: a Rust "micro-proxy" focused on low overhead and simplicity.
  • Cilium: eBPF-based networking and mesh features in the kernel.
  • SPIFFE/SPIRE: the open standard for workload identity used by many meshes.

⚠️ Corner cases

  • Protocol detection failures: a server-first protocol (MySQL, SMTP) through an HTTP-sniffing proxy hangs. Declare ports or protocols explicitly.
  • Headers too large: large JWTs or cookies exceed proxy header limits, causing 431/502 errors that only appear through the mesh.
  • Retries on non-idempotent methods: a mesh-wide default of "retry 2× on 5xx" double-charges. Default to retrying only connect failures and safe/idempotent methods.
  • Health checks through the proxy under strict mTLS: kubelet probes aren't mTLS clients. Probes must be rewritten or exempted.
  • Clock skew: a node clock behind makes fresh certificates "not yet valid". Backdate notBefore by a few minutes at issuance.
  • Egress to external APIs: the mesh must allow and observe outbound traffic through egress gateways, with TLS origination, or teams bypass it.
  • IP reuse: a pod IP is reused by a new pod before endpoint updates converge, so traffic briefly hits the wrong workload. mTLS identity verification is exactly what catches this (the connection fails instead of being misrouted).

Generated from real-life-system-design-guide.md · 8 Oct 2026 16:36