Kubernetes · Service Mesh

Service Mesh Fundamentals

What a sidecar mesh actually buys and what it costs, the four Envoy objects every mesh CRD renders into, what discovery adds on top of kube-dns, and the port name that silently disables every L7 feature.

25 min read Level: core → advanced Service Mesh 01 / 02
The model

HOW A REQUEST GETS INTO THE MESH

The application opens an ordinary connection. Everything after that is interception the app never sees.

CONTROL PLANEistiod / linkerd-destinationconfig → xDSCA issues workload certsINJECTIONwebhook adds the sidecar at admissionPOD Aapp containersidecar proxyCAPTUREiptables / CNI plugin redirects all trafficWIREmTLS, identity = SPIFFE IDPOD Bsidecar proxyapp containerTELEMETRYevery hop reported: latency, code, identity

Because capture is transparent, the app cannot tell you what the mesh did. That is why every debugging path here goes through the proxy rather than the application log.

Diagrams

THREE VIEWS OF THE SAME SYSTEM

The diagram above is the high level: what the pieces are. These two are the ones you want when something is wrong — what is inside one of those boxes, and the path a request really takes through them.

Low levelWhat is inside the sidecar, and what do the CRDs become?
ENVOY SIDECARxDS from istiodapp traffic viaiptablesmTLS to peersidecarmetrics :15090THE FOUR OBJECTS EVERY MESH CRD COMPILES TOListenera port Envoy bindsRoutematch → clusterClustera destination + policyEndpointactual pod IPsFILTER CHAIN ON EACH LISTENERTLS transport socketmTLS terminate / originateHTTP connection managerL7 only if the port is namedrouter filterINTERCEPTIONiptables REDIRECTinstalled by istio-initinbound :15006outbound :15001
VirtualService and DestinationRule are not runtime objects — istiod compiles them into these four. `istioctl proxy-config` prints what the sidecar actually got, which is the only version that matters.
ConnectionWhat does a pod-to-pod call really traverse?
app in pod Aiptables REDIRECTlocalhostsidecar A outbound:15001sidecar B inboundmTLS, SPIFFE IDiptables → app:15006app in pod Blocalhostmetrics + traceheadersapp must forward them
Two extra process hops per call, both localhost, both adding latency and a place to fail. mTLS identity is established between the sidecars — which is why policy can talk about services rather than subnets.
Core

MESH, PROXY AND DISCOVERY

What the parts are, and which decisions actually matter.

A service mesh is two things: a data plane of proxies — one per pod, or one per node — that every request passes through, and a control plane that configures them. The application is not modified and usually does not know the proxy is there.

What you get, and the reason it is worth the weight: mTLS everywhere without touching application code, retries and timeouts and circuit breaking as policy rather than as library code in six languages, traffic splitting for canaries, and uniform golden-signal telemetry for every hop in the system.

The honest costs

  • Latency. Two extra proxy hops per request. Single-digit milliseconds, but not zero, and it compounds across a deep call graph.
  • Resources. A sidecar per pod. At a thousand pods that is a meaningful slice of the cluster spent on proxies.
  • A new failure domain. The mesh can now break your traffic in ways that have nothing to do with your code, and debugging moves from "read the app log" to "read the proxy config".

When you do not need one

Ten services, one language, one team: a shared HTTP client library with retries and timeouts gets most of the benefit at none of the cost. The mesh wins when the count of services and languages is high enough that "put it in the library" stops being one change and becomes six.

Ambient / sidecar-less modes change this calculation. Istio's ambient mode moves mTLS and L4 telemetry to a per-node component and makes the L7 proxy opt-in per namespace, which removes the per-pod sidecar cost for workloads that only need encryption and metrics.

Istio, Gloo, Consul, Gateway API implementations and most of the ecosystem use Envoy as the data plane. Understanding four of its objects makes mesh debugging tractable, because every mesh CRD ultimately renders into them:

ObjectAnswers
ListenerWhat port am I accepting on?
RouteGiven this request, which cluster?
ClusterA named upstream, with its load-balancing and outlier policy
EndpointThe actual pod IPs behind that cluster

xDS is the protocol that streams these from the control plane to every proxy. When someone says "the config has not propagated", they mean a proxy's xDS state is stale — and that is directly observable rather than a matter of opinion.

The debugging move that skips all the guessing

Do not reason about what the CRDs should have produced. Ask the proxy what it actually has.

asking the proxy instead of theorising
$ istioctl proxy-status
NAME CDS LDS EDS RDS
api-7d9f.prod SYNCED SYNCED SYNCED SYNCED
web-5c8a.prod SYNCED STALE SYNCED SYNCED <- here
what does this proxy think the routes are?
$ istioctl proxy-config route api-7d9f.prod --name 8080 -o json | jq '.[0].virtualHosts'
$ istioctl proxy-config cluster api-7d9f.prod --fqdn payments.prod.svc.cluster.local
$ istioctl proxy-config endpoint api-7d9f.prod --cluster 'outbound|8080||payments.prod.svc.cluster.local'
and the single most useful command in the whole toolkit:
$ istioctl analyze -n prod
Warning [IST0101] (VirtualService prod/api) Referenced host not found: "paymnets"

Kubernetes service discovery is already complete: CoreDNS resolves svc.ns.svc.cluster.local to a ClusterIP, and kube-proxy load-balances to the EndpointSlice. A mesh does not replace that — it intercepts after it.

What changes: the proxy has the full endpoint list rather than an iptables rule, so it can do things kube-proxy cannot — least-request instead of random, locality-aware routing that prefers same-zone endpoints and fails over to another zone only when the local ones are unhealthy, outlier detection that ejects an endpoint returning 5xx, and per-request retries with a budget.

Locality routing is usually the biggest single win

Cross-zone traffic costs money and latency in every cloud. With zone labels on the nodes and locality load balancing on, same-zone requests stay in-zone — often a double-digit percentage of inter-AZ transfer removed for one config change.

locality-aware routing with automatic failover
apiVersion: networking.istio.io/v1
kind: DestinationRule
spec:
host: payments.prod.svc.cluster.local
trafficPolicy:
loadBalancer:
localityLbSetting:
enabled: true
failover: [{from: us-east-1a, to: us-east-1b}]
outlierDetection: # eject endpoints that start failing
consecutive5xxErrors: 5
interval: 10s
baseEjectionTime: 30s
verify the proxy has endpoints in more than one locality:
$ istioctl proxy-config endpoint api-7d9f.prod \
--cluster 'outbound|8080||payments.prod.svc.cluster.local' -o json \
| jq -r '.[].hostStatuses[].locality'
IstioLinkerd
ProxyEnvoy (C++), very configurablelinkerd2-proxy (Rust), purpose-built, small
SurfaceLarge — many CRDs, many knobsDeliberately small
Resource costHigher per sidecarNotably lower
Sidecar-lessAmbient mode
StrengthAnything you can express, you can configureFewer ways to get it wrong

The decision is rarely about capability, because both do the core job. It is about whether you want the configurability and will staff for it, or want the smallest thing that provides mTLS, retries and golden signals.

Gateway API is where both are heading

The Kubernetes Gateway API is the successor to Ingress and now has a service-mesh profile (GAMMA). Both Istio and Linkerd implement it. For new configuration it is worth preferring Gateway API resources over vendor CRDs where they cover your case — the config outlives the mesh you picked.

In practice

ADVANCED TROUBLESHOOTING

Mesh debugging has one rule: stop reasoning about the CRDs and ask the proxy. The CRDs are intent; the proxy's xDS state is what is actually happening, and the gap between them is where the bug lives.

SymptomLikely causeCommand
503 with UC/UF flagsUpstream refused or unreachableistioctl proxy-config endpoint — is the list empty?
503 NR (no route)No VirtualService matches this host/portistioctl proxy-config route
Works without the sidecar, fails with itPort naming, or a protocol the mesh mis-detectedService port must be named http, grpc, tcp
mTLS handshake failuresPeerAuthentication STRICT with a non-mesh clientistioctl x describe pod <p>
Config change did nothingProxy has stale xDSistioctl proxy-status — anything not SYNCED
Some pods not meshedInjection label missing, or the pod predates itkubectl get ns -L istio-injection; then restart
Intermittent 503 on deployNo graceful drain — in-flight requests cutterminationGracePeriodSeconds and a preStop sleep

Port naming is the one that wastes a whole afternoon

Istio infers protocol from the Service port name. A port named web is treated as plain TCP, so HTTP routing, retries, and L7 telemetry silently do nothing — no error, just features that are quietly absent. Name it http, or use appProtocol.

the checks, in the order worth running them
$ istioctl analyze -A
1. is every proxy current?
$ istioctl proxy-status | grep -v SYNCED
2. what does THIS pod believe about mTLS and policy?
$ istioctl x describe pod api-7d9f.prod
3. the actual request flags — 15 seconds of proxy log beats an hour of theory
$ kubectl logs api-7d9f -c istio-proxy --tail=50 | grep -v '" 200 '
[2026-09-15T09:12:44Z] "GET /v1/charge HTTP/1.1" 503 UF,URX
UF = upstream connection failure. Endpoints, not routes.
4. are there any endpoints at all?
$ istioctl proxy-config endpoint api-7d9f.prod | grep payments
5. the port-name check that explains 'L7 features do nothing'
$ kubectl get svc -A -o json | jq -r '.items[] | .metadata.namespace as $ns
| .metadata.name as $n | .spec.ports[]
| select((.name//"")|test("^(http|https|grpc|tcp|tls|mongo|redis)")|not)
| "\($ns)/\($n) port \(.port) name=\(.name // "UNNAMED")"'
The proxy access log has the answer in two characters

Envoy tags every request with response flags — UF upstream failure, UH no healthy upstream, NR no route, UO outlier-ejected, URX retry limit. They distinguish 'nothing to send it to' from 'nowhere to route it' immediately, which is the fork you would otherwise spend twenty minutes narrowing by hand.

Reference

CHEATSHEET

CommandWhat it answers
istioctl analyze -AMisconfiguration, before it becomes an incident
istioctl proxy-statusWhich proxies have stale config
istioctl x describe pod <p>Policies, mTLS mode and routes for one pod
istioctl proxy-config route <p>The routes this proxy actually has
istioctl proxy-config cluster <p>Upstreams it knows about
istioctl proxy-config endpoint <p>Real pod IPs — empty means 503
istioctl proxy-config listener <p>Ports it is accepting on
kubectl logs <p> -c istio-proxyResponse flags — the two-character diagnosis
kubectl get ns -L istio-injectionWhich namespaces are meshed
linkerd checkLinkerd's equivalent end-to-end health check
linkerd viz stat deploy -n <ns>Success rate, RPS and latency per workload