Kubernetes · Service Mesh

Service Mesh Operations

mTLS identity that makes policy a statement about services rather than subnets, the AuthorizationPolicy default that flips one workload to deny, retries that multiply through a call graph, and the trace headers your app still has to forward itself.

26 min read Level: advanced Service Mesh 02 / 02
The model

IDENTITY FIRST, THEN EVERY POLICY ABOVE IT

Once each workload has a cryptographic identity, authorization and routing stop being about IP addresses.

IDENTITYSPIFFE ID per ServiceAccountmesh CA issues certrotated hourlyTRANSPORTPeerAuthentication: STRICT | PERMISSIVEAUTHZAuthorizationPolicy: ALLOW / DENY / CUSTOMROUTINGVirtualService: match, split, retry, timeoutUPSTREAMDestinationRule: subsets, LB, outlier, circuit breakerTELEMETRYmetrics per hopaccess logstrace headers — app must propagate

Telemetry sits at the bottom because it is a by-product: everything already passes through the proxy, so metrics come free — and traces do not, because they need the application to cooperate.

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 istiod?
ISTIODkube-apiserverCSR from sidecarsxDS config pushsigned certsCONFIGwatch CRDsVirtualService, DestinationRulewatch Services + Endpointsfrom Kubernetespush contextone snapshot per revisionDISTRIBUTExDS servergRPC :15012debounce + throttle100ms, batchedper-proxy scopingSidecar resource narrows itIDENTITYCAsigns workload certsSPIFFE ID per SAspiffe://td/ns/<ns>/sa/<sa>cert rotation 24h
Every sidecar gets the whole mesh's config unless a Sidecar resource narrows it. On a large mesh that is the difference between a 2 MB push and a 200 KB one, and it is the first thing to fix when istiod burns CPU.
ConnectionYou applied an AuthorizationPolicy — what happens next?
kubectl applyapiserverCRD storedistiod watch~100ms debouncexDS pushgRPC :15012sidecars updateno restartfirst matching workloadnow DEFAULT DENYits neighboursunchanged
The trap is the last two hops. An AuthorizationPolicy that selects a workload flips that workload to deny-by-default; workloads it does not select stay wide open. One policy does not secure a namespace.
Core

SECURITY, ROUTING AND TELEMETRY

The four things a mesh is actually run for, and where each one bites.

The mesh issues every workload a short-lived X.509 certificate whose identity is a SPIFFE ID derived from its ServiceAccount:

spiffe://cluster.local/ns/prod/sa/payments-sa

Both ends present one, both verify, and rotation is automatic and frequent — typically hourly. That is the part worth appreciating: certificate rotation, the thing that causes outages everywhere else, becomes invisible infrastructure.

And crucially, this identity is cryptographic, not network-based. Policy can now say "payments may call ledger" rather than "10.4.0.0/16 may reach port 8080" — which survives pods moving, IPs changing and namespaces being recreated.

PERMISSIVE is the migration mode, and the trap

PERMISSIVE accepts both mTLS and plaintext, which is what makes incremental adoption possible. It is also indistinguishable from STRICT when everything happens to be meshed — so clusters sit in PERMISSIVE for years believing they have mutual TLS, while any unmeshed pod can still connect in plaintext.

moving to STRICT, and proving it took
namespace-wide, after everything in it is meshed
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata: { name: default, namespace: prod }
spec: { mtls: { mode: STRICT } }
what is each workload ACTUALLY doing right now?
$ istioctl x describe pod api-7d9f.prod | grep -i mtls
the honest test — a plaintext client from outside the mesh:
$ kubectl run probe --rm -it --image=curlimages/curl \
--annotations sidecar.istio.io/inject=false -- \
curl -sS -m 5 http://payments.prod.svc.cluster.local:8080/health
curl: (56) Recv failure: Connection reset by peer <- STRICT is real
any namespace still permissive:
$ kubectl get peerauthentication -A -o json | jq -r '.items[]
| "\(.metadata.namespace)/\(.metadata.name)\t\(.spec.mtls.mode // "unset")"'

With identity established, authorization becomes a statement about services rather than addresses. The evaluation order is worth committing to memory, because it is where surprises come from:

  1. CUSTOM policies (external authz) evaluate first.
  2. DENY policies — if any matches, the request is refused.
  3. If any ALLOW policy applies to the workload, the request must match one.
  4. If no ALLOW policy applies to the workload at all, the request is allowed.

Point 4 is the one that catches people. A namespace with no AuthorizationPolicy is fully open. Adding one ALLOW policy to a single workload flips that workload to default-deny while everything beside it stays open — which is usually intended, and rarely realised.

The default-deny baseline

An ALLOW policy with an empty spec: {} matches nothing and therefore denies everything in its namespace. That is the idiom for a default-deny floor, and it reads as a typo if you have not seen it before.

default-deny, then allow exactly what should exist
1. the floor: allow nothing (empty spec matches no request)
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata: { name: deny-all, namespace: prod }
spec: {}
2. one allowance, by IDENTITY not by IP
spec:
selector: { matchLabels: { app: ledger } }
action: ALLOW
rules:
- from: [{ source: { principals:
["cluster.local/ns/prod/sa/payments-sa"] } }]
to: [{ operation: { methods: ["POST"], paths: ["/v1/entries"] } }]
a refusal is RBAC: access denied, and the proxy log names the policy:
$ kubectl logs ledger-6f8 -c istio-proxy | grep -i rbac | tail -5
Test policy from a real caller, not from curl in a debug pod

A debug pod carries its own ServiceAccount, so it has a different SPIFFE ID and will be refused for reasons that have nothing to do with the policy you are testing. Exec into an actual client pod, or run the probe with the same ServiceAccount.

VirtualService decides routing — match on header, path or weight; DestinationRule defines the subsets and the upstream policy. A canary is a weight change, and a header-matched route lets you send only your own traffic to the new version first, which is a better first step than 1% of everyone.

Retries are the dangerous feature

Mesh retries are per-hop, and they multiply through a call graph. Three retries at each of three hops is up to 27 requests hitting the service at the bottom — so a service that is struggling receives an order of magnitude more load precisely because it started failing. This is the mechanism behind a large share of mesh-amplified outages.

Three rules keep it safe: retry only idempotent operations, keep perTryTimeout × attempts under the overall timeout, and pair retries with outlier detection so a consistently failing endpoint is ejected rather than retried at.

a canary and a retry policy that will not amplify
kind: VirtualService
spec:
hosts: [payments]
http:
- match: [{ headers: { x-canary: { exact: "true" } } }] # opt-in first
route: [{ destination: { host: payments, subset: v2 } }]
- route: # then weight
- { destination: { host: payments, subset: v1 }, weight: 95 }
- { destination: { host: payments, subset: v2 }, weight: 5 }
timeout: 3s
retries:
attempts: 2 # 2, not 5
perTryTimeout: 1s # 2 x 1s < 3s overall
retryOn: 5xx,reset,connect-failure
and the circuit breaker that stops you retrying at a dead endpoint:
kind: DestinationRule
spec:
trafficPolicy:
outlierDetection: { consecutive5xxErrors: 5, interval: 10s,
baseEjectionTime: 30s, maxEjectionPercent: 50 }
connectionPool: { http: { http2MaxRequests: 100,
maxRequestsPerConnection: 10 } }

Because every request crosses a proxy, the mesh reports request rate, error rate and latency distribution for every service-to-service hop, labelled with both identities, with no application instrumentation at all. That is the fastest observability win available in a Kubernetes estate, and it arrives the day you install the mesh.

The tracing caveat

Distributed tracing is not free. The mesh generates and forwards span context, but the application must propagate the trace headers (traceparent, or the x-b3-* family) from the incoming request to its outgoing calls. An app that does not becomes a wall: every trace stops there and you get disconnected fragments rather than a call graph. It is a handful of lines in most frameworks, and it is the single most common reason mesh tracing "does not work".

Multi-cluster mesh

Two shapes. Multi-primary puts a control plane in each cluster — no cross-cluster control dependency, more to keep consistent. Primary-remote has one control plane serving several clusters — simpler config, and a hard dependency on the primary. Either way the prerequisites are the same and are the actual work: a shared trust root so identities verify across clusters, pod-to-pod reachability or east-west gateways, and consistent namespace and ServiceAccount naming, because identity is derived from those names.

what the mesh already knows, without app changes
success rate and latency per hop — no instrumentation
$ kubectl exec -n istio-system deploy/prometheus -c prometheus -- \
curl -sG localhost:9090/api/v1/query --data-urlencode 'query='\
'sum by (destination_service_name) (
rate(istio_requests_total{response_code=~"5.."}[5m]))'
p99 per service pair
histogram_quantile(0.99, sum by (le, source_workload, destination_service_name)(
rate(istio_request_duration_milliseconds_bucket[5m])))
linkerd states it directly:
$ linkerd viz stat deploy -n prod
NAME MESHED SUCCESS RPS LATENCY_P99
payments 3/3 99.82% 412 184ms
is the app actually propagating trace context? look for it downstream:
$ kubectl logs payments-6f8 -c istio-proxy | grep -o 'traceparent[^ ]*' | head -3
In practice

ADVANCED TROUBLESHOOTING

Operational mesh failures split cleanly in two: policy refused it, which is loud and precise, or routing sent it nowhere, which shows as a 503 with a two-character flag. Identify which before touching any YAML.

What you seeMeaningWhere to look
RBAC: access deniedAuthorizationPolicy refused itProxy log names the policy; check the caller's SPIFFE ID
503 UFCould not connect upstreamEndpoints, and mTLS mode mismatch
503 UHNo healthy upstream — all ejectedOutlier detection is doing its job
503 NRNo route matchedVirtualService hosts and ports
503 UOCircuit breaker openconnectionPool limits
Load spike on a failing serviceRetries amplifying through the graphattempts per hop, multiplied
Traces stop at one serviceThat app does not forward trace headersIts outgoing request headers
Cross-cluster calls failTrust root or gatewayistioctl proxy-config endpoint — remote endpoints present?

The mTLS mismatch that presents as a connection reset

A STRICT namespace and an unmeshed caller produce a reset with no useful application-level error. It is easy to misread as a network problem. The tell: it works from inside the mesh and fails from outside, and the server-side proxy log shows the connection being closed before any request line.

narrowing a mesh 503 in four commands
$ kubectl logs api-7d9f -c istio-proxy --tail=100 | grep ' 503 '
[...] "POST /v1/charge HTTP/1.1" 503 UF,URX "-" 0 91 1001 - "-" ...
UF -> connection failure. Is there anything to connect TO?
$ istioctl proxy-config endpoint api-7d9f.prod --cluster \
'outbound|8080||payments.prod.svc.cluster.local'
endpoints present -> mTLS mismatch. Compare both sides:
$ istioctl x describe pod payments-6f8.prod | grep -iA2 mtls
and confirm the policy set actually in force for that workload:
$ kubectl get peerauthentication,authorizationpolicy -n prod
istioctl analyze before anything else

It catches the majority of real-world mesh misconfiguration statically — hosts that do not resolve, conflicting policies, subsets with no matching DestinationRule, gateways selecting nothing. Running it in CI against your manifests catches these before they reach a cluster at all.

Reference

CHEATSHEET

CommandWhat it answers
istioctl analyze -AStatic misconfiguration across the mesh
istioctl x describe pod <p>Every policy in force for one workload
kubectl get peerauthentication -AWhich namespaces are actually STRICT
kubectl get authorizationpolicy -AWho may call whom
kubectl logs <p> -c istio-proxy | grep rbacWhich policy refused a request
kubectl logs <p> -c istio-proxy | grep ' 503 'The response flag — the actual diagnosis
istioctl proxy-config endpoint <p>Whether there is anything to route to
istioctl proxy-config route <p>Whether a route matches at all
istioctl pc secret <p>The workload cert and its validity
linkerd viz stat deploy -n <ns>Success rate, RPS, p99 per workload
linkerd viz tap deploy/<x>Live per-request stream
istioctl proxy-statusStale config, before you debug the wrong thing