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
Sidecarresource 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
notBeforeby 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).