Service Mesh
BoxOffice now runs nine services in Kubernetes. Each team has re-implemented the same things in its own way: order has a 2-second timeout to payment, inventory has none; catalog retries three times, search retries forever; nobody encrypts traffic inside the cluster because "it's internal"; and when security asks "which services can call payment?", the honest answer is "any pod that knows the URL". A service mesh moves these cross-cutting concerns β encryption, identity, authorization, timeouts, retries, traffic splitting, telemetry β out of the application code and into a uniform infrastructure layer.
What a service mesh is
A service mesh is a dedicated infrastructure layer that handles service-to-service communication1. It has two parts:
- Data plane β proxies that sit in the path of every request between services. They terminate and originate TLS, enforce policies, retry, time out, balance load and emit metrics and traces.
- Control plane β the brain that configures the proxies: it distributes routing rules and policies, and acts as a certificate authority that issues each workload its identity.
flowchart TB
subgraph cp ["Control plane (istiod / linkerd-control-plane)"]
CFG["config + policy"]
CA["certificate authority"]
end
subgraph dp ["Data plane"]
subgraph po ["pod: order"]
O["order app"] --- PO["proxy"]
end
subgraph pp ["pod: payment"]
PP["proxy"] --- P["payment app"]
end
end
PO <-->|"mTLS"| PP
CFG -.->|"routes, policies"| PO & PP
CA -.->|"certificates (rotated)"| PO & PP The application still calls http://payment:8080/charges in plain HTTP. The proxy on the way out upgrades it to mutual TLS, the proxy on the way in verifies the caller's identity, checks the policy and hands plain HTTP to payment. Neither application changed a line.
What you get
| Capability | Without a mesh | With a mesh |
|---|---|---|
| Encryption in transit | each team configures TLS (usually nobody does inside the cluster) | mTLS everywhere, certificates rotated automatically |
| Service identity | IP addresses, shared secrets | a cryptographic identity per workload (SPIFFE) |
| Authorization between services | in code, if at all | declarative policies: "only order may POST /charges on payment" |
| Timeouts, retries, circuit breaking | libraries in each language (Resilience4j, Pollyβ¦) | uniform config per route |
| Traffic splitting (canary, mirroring) | in the load balancer or in code | weights per route, header-based routing |
| Golden-signal telemetry | each service instruments itself | request rate, errors and latency for every hop, out of the box |
The mesh is the natural home of three topics of the course: reliability patterns (see Reliability), observability of every hop (see Observability) and Zero Trust between services (Zero Trust).
Data-plane architectures: sidecar, ambient, eBPF
Where does the proxy run? Three designs are in production today23.
One proxy container (Envoy for Istio, linkerd2-proxy for Linkerd) is injected into every pod. All traffic in and out of the pod is redirected through it.
- Every hop is full L7 (HTTP-aware): retries, header routing, per-path policies.
- Cost: one proxy per pod β memory and CPU multiplied by the number of pods, an extra hop on each side of every call, and the sidecar's lifecycle is coupled to the app (upgrading the mesh means restarting every pod; jobs need the sidecar to exit).
The mesh is split into two layers:
- ztunnel β one per node, a small proxy that handles L4: mTLS, identity, L4 authorization and telemetry for all pods on the node.
- waypoint proxy β an optional Envoy deployed per namespace or per service only when L7 features are needed (HTTP routing, retries, path-based policies).
Pods are not modified; adding a namespace to the mesh is a label. You pay for L7 only where you use it.
The eBPF (extended Berkeley Packet Filter) programs in the Linux kernel handle L3/L4 β routing, NetworkPolicy, load balancing, transparent encryption (WireGuard or IPsec) β with no proxy per pod. L7 features go through a shared per-node Envoy. Identity is tied to Kubernetes labels and, for mutual authentication, to SPIFFE-style identities.
| Sidecar | Ambient | eBPF (Cilium) | |
|---|---|---|---|
| Proxy placement | one per pod | ztunnel per node + optional waypoint | kernel + shared Envoy per node |
| L7 everywhere? | yes | only where a waypoint is deployed | only where L7 policy is configured |
| Resource overhead (qualitative) | highest β grows with pod count | low for L4, pay per waypoint | lowest for L3/L4 |
| Added latency per hop | two proxies on the path | node-level hop; +waypoint for L7 | kernel fast path |
| Upgrade impact | restart every pod | upgrade node agents and waypoints | upgrade the node agent |
| Blast radius of a proxy bug | one pod | all pods on the node (ztunnel) | the node |
| Maturity | longest in production | GA in Istio since 1.24 (2024) | widely used as CNI; mesh features newer |
Proxyless gRPC
A fourth option skips the proxy entirely: gRPC libraries can receive mesh configuration directly from the control plane (the xDS protocol). It is efficient but only works for gRPC services written with a supporting library.
Think first: BoxOffice has 9 services Γ 6 replicas, and each sidecar reserves 50 MB of memory. What does the mesh cost in memory?
54 pods Γ 50 MB β 2.7 GB just for sidecars (an illustrative reservation; measure yours), plus CPU proportional to the traffic. During a flash sale, when the autoscaler takes order and inventory to 40 replicas each, the sidecars scale too. That linear cost is the main motivation for ambient and eBPF data planes.
Identity and mTLS
How does payment know that a call really comes from order? IP addresses prove nothing β pods are recreated with new IPs, and the attacker in the compromised search pod can send from any IP it likes. The mesh gives each workload a cryptographic identity.
- TLS (Transport Layer Security) β the server proves its identity with an X.509 certificate signed by a certificate authority; the channel is encrypted.
- mTLS (mutual TLS) β the client also presents a certificate. Both sides are authenticated.
- SPIFFE (Secure Production Identity Framework For Everyone)4 β a standard name for workload identities,
spiffe://<trust-domain>/<path>. Istio usesspiffe://cluster.local/ns/<namespace>/sa/<service-account>: the identity is the Kubernetes service account. - SVID (SPIFFE Verifiable Identity Document) β the short-lived X.509 certificate that carries that identity, rotated automatically (Istio's default lifetime is 24 hours; SPIRE's default is one hour).
sequenceDiagram
autonumber
participant O as order (proxy)
participant CA as Mesh CA (istiod)
participant P as payment (proxy)
O->>CA: CSR + proof of service account token
CA-->>O: SVID spiffe://cluster.local/ns/boxoffice/sa/order (24 h)
P->>CA: CSR + proof of service account token
CA-->>P: SVID spiffe://cluster.local/ns/boxoffice/sa/payment
O->>P: ClientHello
P-->>O: payment certificate + CertificateRequest
O->>O: verify payment's SVID against the trust bundle
O->>P: order certificate + proof of private key
P->>P: verify order's SVID, read its SPIFFE ID
P->>P: authorization policy: may sa/order POST /charges? β allow
Note over O,P: encrypted channel, both identities proven, policy applied Turn on strict mTLS
By default Istio accepts both plain-text and mTLS (permissive mode) so that services can migrate gradually. Once every workload has a proxy, make it strict:
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
namespace: boxoffice
spec:
mtls:
mode: STRICT # reject any plain-text connection to pods in this namespace
mTLS authenticates; a policy authorizes
A valid certificate says who is calling β not whether they may. That is an authorization policy, evaluated by the proxy on every request. Only order may charge cards:
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: payment-allow-order
namespace: boxoffice
spec:
selector:
matchLabels:
app: payment
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/boxoffice/sa/order"]
to:
- operation:
methods: ["POST"]
paths: ["/charges", "/refunds"]
Once a workload has at least one ALLOW policy, any request that matches no rule is denied. The compromised search pod gets 403 RBAC: access denied.
# API versions change between Linkerd releases: check yours
apiVersion: policy.linkerd.io/v1beta3
kind: Server
metadata:
name: payment-http
namespace: boxoffice
spec:
podSelector:
matchLabels:
app: payment
port: 8080
proxyProtocol: HTTP/1
---
apiVersion: policy.linkerd.io/v1alpha1
kind: MeshTLSAuthentication
metadata:
name: order-only
namespace: boxoffice
spec:
identities:
- "order.boxoffice.serviceaccount.identity.linkerd.cluster.local"
---
apiVersion: policy.linkerd.io/v1alpha1
kind: AuthorizationPolicy
metadata:
name: payment-allow-order
namespace: boxoffice
spec:
targetRef:
group: policy.linkerd.io
kind: Server
name: payment-http
requiredAuthenticationRefs:
- group: policy.linkerd.io
kind: MeshTLSAuthentication
name: order-only
Linkerd names the identity after the service account too; the policy attaches to a Server (a port of a set of pods).
BoxOffice β the service call graph as policy
Draw the allowed edges once and turn each into a policy: gateway β order, catalog, search, auth; order β inventory, payment, notification (via Kafka); assistant β catalog, order (read-only). Everything else is denied. The diagram in the design document and the policies in the repository are the same thing β reviewable in a pull request.
Traffic policy
The second big job of a mesh is shaping traffic: timeouts, retries, circuit breaking and splitting.
Timeouts and retries per route
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: payment
namespace: boxoffice
spec:
hosts: ["payment"]
http:
- route:
- destination:
host: payment
subset: v1
weight: 90
- destination:
host: payment
subset: v2 # canary: 10% of the traffic
weight: 10
timeout: 3s # total budget for the call
retries:
attempts: 2
perTryTimeout: 1s
retryOn: connect-failure,refused-stream,unavailable
Three decisions are encoded here: a canary sends 10% of the calls to v2; the whole call has a 3-second budget; and retries happen only on failures where the request certainly did not reach the application (connect-failure, refused-stream, unavailable). Retrying a POST /charges after a timeout could charge the customer twice β that is what idempotency keys are for (see Compute β integrations).
Circuit breaking with outlier detection
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: payment
namespace: boxoffice
spec:
host: payment
subsets:
- name: v1
labels: { version: v1 }
- name: v2
labels: { version: v2 }
trafficPolicy:
connectionPool:
http:
http2MaxRequests: 200 # bulkhead: max concurrent requests
maxRequestsPerConnection: 50
outlierDetection:
consecutive5xxErrors: 5 # eject a replica after 5 errors in a row
interval: 10s
baseEjectionTime: 30s
maxEjectionPercent: 50 # never eject more than half the replicas
Outlier detection removes a misbehaving replica from the load-balancing pool for a while β a per-instance circuit breaker. The connection pool limits are a bulkhead: when payment is slow, at most 200 requests wait on it, instead of every thread of order.
Try it. Open the cascading-failure scenario: a slow third-party dependency with no timeouts exhausts the caller's threads, and unrelated requests start failing. Turn on the timeout and the circuit breaker, run again, and compare the error rate and p99 of the other routes. In BoxOffice, the mesh VirtualService timeout and DestinationRule limits are one way to apply exactly that fix without touching the code.
Retries in two places multiply
If order uses Resilience4j with 3 attempts and the mesh adds 2 more per attempt, one user click can become 3 Γ 3 = 9 calls to a struggling payment β a retry storm. Decide one layer that retries (usually the mesh, or the client library, not both) and keep a retry budget.
Gateway API for the mesh (GAMMA)
The Kubernetes Gateway API started as the successor of Ingress for northβsouth traffic (see Edge). The GAMMA initiative extends the same HTTPRoute resource to eastβwest traffic inside the mesh, so a canary looks the same in Istio, Linkerd or Cilium:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: payment-canary
namespace: boxoffice
spec:
parentRefs:
- group: ""
kind: Service # attach to a Service = mesh (east-west) routing
name: payment
port: 8080
rules:
- backendRefs:
- name: payment-v1
port: 8080
weight: 90
- name: payment-v2
port: 8080
weight: 10
Mesh policy and NetworkPolicy: two layers
A Kubernetes NetworkPolicy is a firewall for pods at L3/L4 (IP and port), enforced by the CNI plugin (Calico, Cilium)5. A mesh authorization policy works at L7 with cryptographic identity. They are complementary layers of defense in depth:
| NetworkPolicy | Mesh authorization policy | |
|---|---|---|
| Layer | L3/L4 β IPs, ports | L7 β identity, method, path, headers |
| Identity | pod labels β IPs | SPIFFE identity in the certificate |
| Enforced by | the CNI, in the kernel | the proxy (sidecar, ztunnel, waypoint) |
| Survives a bypassed proxy? | yes | no |
Can say "only POST /charges"? | no | yes |
Start with default deny at the network layer, then open only the edges of the call graph:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: boxoffice
spec:
podSelector: {}
policyTypes: ["Ingress"]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: payment-from-order
namespace: boxoffice
spec:
podSelector:
matchLabels:
app: payment
policyTypes: ["Ingress"]
ingress:
- from:
- podSelector:
matchLabels:
app: order
ports:
- port: 8080
Egress is the forgotten half
Most teams write ingress policies only. Egress policies (and an egress gateway) decide what a pod may reach outside: only payment should reach the provider's address; search should reach nothing on the Internet. That is what turns a compromised pod into a dead end instead of a data-exfiltration channel.
Costs and when not to use a mesh
A mesh is powerful and expensive to run. It adds a control plane to operate and upgrade, proxies that consume CPU and memory, a few milliseconds per hop, a new class of failures ("the proxy rejected it"), and a steep learning curve for debugging.
| Situation | Recommendation |
|---|---|
| 3β5 services, one team, one language | probably no mesh: TLS at the ingress, NetworkPolicy, a resilience library |
| Many services, several languages and teams | a mesh gives uniform mTLS, policy and telemetry |
| Compliance requires encryption in transit everywhere | a mesh (or CNI-level encryption) is the cheapest way to prove it |
| Latency-critical hot path (microseconds) | measure; consider ambient/eBPF or proxyless gRPC |
| Only need mTLS + L4 policy | ambient (ztunnel only) or Cilium, without L7 proxies |
In the interview
Bring up a mesh when the design has many services and someone asks about service-to-service security or resilience: "inside the cluster, a service mesh gives every service an identity and mutual TLS; authorization policies say only order can call payment; timeouts, retries and outlier detection are configured per route." Then show you know the cost: "it adds a proxy hop and operational complexity β ambient or eBPF data planes reduce the per-pod overhead; for a handful of services I'd skip it."
Best practices
- Roll out mTLS in steps with a deadline β permissive mode, watch the plaintext-connection metric fall to zero, then STRICT per namespace on a date written in the plan.
- Derive authorization policies from the call graph β deny by default per namespace, then allow each edge (
order β payment) by service identity; the graph doubles as documentation. - Put retries in one layer only, with a retry budget β e.g. at most 20% extra requests, and only for idempotent methods; the other layer gets timeouts.
- Make each hop's timeout shorter than its caller's β the gateway waits 5 s (lab 4 sets
response-timeout: 5s), soorder β paymentmust give up well before that. - Budget the data plane β set CPU and memory requests for the proxies, and count about 1 ms per hop in the latency budget of a path with 4β5 hops.
- Keep workload certificates short-lived and rotated by the mesh β hours, not years (Istio issues 24-hour certificates by default), so a stolen certificate expires on its own.
Common pitfalls
- Leaving mTLS in permissive mode forever β plain text is still accepted, so the mesh encrypts nothing an attacker cares about.
- mTLS without authorization policies β every workload in the mesh can still call
payment; you only encrypted the attack. - Double retries (library + mesh) β retry storms on the first incident.
- Retrying non-idempotent calls after a timeout β double charges.
- Assuming the mesh replaces user authorization β the mesh knows the service; only
orderknows whether this user owns order 1042. - Forgetting egress β the mesh protects calls between services, but the compromised pod can still reach the Internet unless egress is restricted.
How it connects
flowchart LR
CP["control plane<br/>config + CA"] --> DP["data plane:<br/>sidecar, ambient, eBPF"]
CP --> SVID["SPIFFE identity (SVID)<br/>from the service account"]
SVID --> MTLS["strict mTLS"]
MTLS --> AUTHZ["authorization policy<br/>only order β payment"]
AUTHZ --> NP["NetworkPolicy L3/L4<br/>default deny + egress"]
AUTHZ --> ZT["PEP for workloads<br/>β Zero Trust"]
DP --> TP["timeouts, retries,<br/>outlier detection"]
TP --> REL["resilience patterns<br/>β Reliability"]
DP --> CAN["HTTPRoute weights (GAMMA)<br/>β canary in Cloud-native"]
DP --> TEL["golden signals per hop<br/>β Observability"] Palette components: mesh Β· app (the services behind each proxy) Β· ingress (Gateway API, northβsouth) Β· metrics Β· tracing (telemetry of every hop). Scenarios: cascading-failure, retry-storm. Continues in: Zero Trust, Reliability β patterns, Observability β signals, Edge β load balancing in Kubernetes, Cloud-native β delivery (canary).
Key takeaways
- A service mesh = data plane (proxies on every call) + control plane (config + certificate authority); it moves mTLS, policy, resilience and telemetry out of the code.
- Sidecar gives full L7 everywhere at a per-pod cost; ambient splits L4 (ztunnel per node) from optional L7 (waypoints); eBPF does L3/L4 in the kernel.
- mTLS authenticates workloads with SPIFFE identities derived from service accounts; an authorization policy decides who may call what.
- Traffic policy β timeouts, retries, outlier detection, canary weights β is declared per route; retry in only one layer, and only idempotent calls.
- NetworkPolicy (L3/L4, default deny, ingress and egress) and mesh policy (L7, identity) are complementary layers. For a handful of services, a mesh may not be worth it.
-
MORGAN, W. What's a service mesh? And why do I need one?. Buoyant. See also the Linkerd documentation.Β β©
-
ISTIO. Ambient mode overview and Security β authorization policy.Β β©
-
CILIUM. Cilium Service Mesh.Β β©
-
SPIFFE. SPIFFE Overview.Β β©
-
KUBERNETES. Network Policies; Gateway API β GAMMA (service mesh).Β β©