stuff I figured out the hard way

Key points:

  • Adopt a mesh for per-request mTLS identity and authorization between services; most other needs have cheaper answers.
  • Default to Linkerd for the simplest mTLS and metrics; pick Istio (ambient first) for depth; pick Cilium’s mesh if you already run Cilium.
  • Roll out mTLS as PERMISSIVE, verify, then STRICT - STRICT cuts off everything outside the mesh.
  • Retry in one layer only, and nest timeouts so outer ones are longer than inner ones.
  • Treat the control plane as tier-0: run it highly available and monitor certificate expiry.

What a mesh gives you

A mesh moves networking out of app code into infrastructure:

  • Mutual TLS everywhere - every service-to-service connection encrypted, both sides authenticated with auto-rotating certificates.
  • Identity-based authorization - “only checkout may call POST /charge on payments”, enforced on identity, not IP.
  • Traffic control - timeouts, retries, circuit breaking, weighted splits for canaries.
  • Uniform telemetry - request rate, errors and latency per service, no code changes.

Do you need one?

Check the cheaper option for each need first - a mesh is a significant system to run.

NeedWithout a mesh
Encrypt traffic between nodesCilium or Calico WireGuard encryption
Restrict which pods can talkNetworkPolicy (identity-based with Cilium)
North-south routing, TLS, rate limitsAn ingress or Gateway API controller (Envoy Gateway)
Retries and timeoutsClient libraries, gRPC’s built-in retry policy
Per-request mTLS identity and authorization between servicesThis is what a mesh is for
Golden metrics for every service without instrumenting each oneAlso a mesh strength

Use when: compliance requires mTLS between services, you need zero-trust authorization across many teams, or many services in many languages make consistent in-code retries and telemetry unrealistic.

Architectures

  • Sidecar (Istio with Envoy, Linkerd with its own Rust micro-proxy) - a proxy container in every pod. Full L7 per pod; costs a proxy’s CPU and memory in every pod.
  • Ambient / sidecarless (Istio) - a per-node ztunnel does mTLS and L4; optional per-namespace waypoint proxies add L7 where needed. No pod restarts to join.
  • eBPF plus per-node proxy (Cilium) - L3/L4 in the kernel, L7 through a shared Envoy per node.
flowchart LR
    subgraph SC["Sidecar"]
        PA["Pod A<br/>app + proxy"] -->|mTLS| PB["Pod B<br/>proxy + app"]
    end
    subgraph AM["Ambient"]
        QA["Pod A<br/>app only"] --> ZA[ztunnel<br/>node 1]
        ZA -->|mTLS| W["waypoint<br/>(only if L7 needed)"]
        W --> ZB[ztunnel<br/>node 2]
        ZB --> QB["Pod B<br/>app only"]
    end

Every mesh has a control plane (certificates, config) and a data plane (the proxies). Workload identity is usually a SPIFFE ID from the service account, e.g. spiffe://cluster.local/ns/payments/sa/payments.

Choosing

IstioLinkerdCilium Service Mesh
Data planeEnvoy sidecars, or ambient (ztunnel + waypoints)Rust micro-proxy sidecarseBPF + per-node Envoy
FeaturesThe most: L7 routing, authz, egress gateways, multi-clusterFocused: mTLS, retries, timeouts, metrics, authzBuilds on Cilium networking and policy
Operational weightHeaviest, though ambient is lighterLightest to runLight if you already run Cilium
ReleasesOpen source stable releasesOpen source ships edge releases; stable releases come from vendors since February 2024Part of Cilium releases
  • Default: Linkerd - the simplest path to mTLS and metrics.
  • Use Istio when: you need its depth. Start with ambient for lower overhead.
  • Use Cilium’s mesh when: you already run Cilium and mainly need L7 visibility and policy.

The core configuration (Istio)

Require mTLS mesh-wide, then allow only what’s needed:

apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: istio-system      # root namespace = mesh-wide
spec:
  mtls:
    mode: STRICT               # migrate from PERMISSIVE first
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: payments-allow-checkout
  namespace: payments
spec:
  selector:
    matchLabels:
      app: payments
  action: ALLOW
  rules:
    - from:
        - source:
            principals: ["cluster.local/ns/checkout/sa/checkout"]
      to:
        - operation:
            methods: ["POST"]
            paths: ["/charge"]

Set timeouts and bounded retries:

apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: payments
  namespace: payments
spec:
  hosts: [payments]
  http:
    - route:
        - destination:
            host: payments
      timeout: 2s
      retries:
        attempts: 2
        perTryTimeout: 500ms
        retryOn: connect-failure,refused-stream,unavailable,503

Gotchas

  • STRICT mTLS breaks everything outside the mesh. Ingress controllers, Prometheus scrapers and un-meshed jobs present no mesh certificate and get cut off. Run PERMISSIVE, confirm in telemetry that all traffic is mTLS, then switch.
  • Startup races. A classic sidecar may start after the app, so the first connections fail. Use native sidecars (GA in 1.33), which start first; in Istio, also see holdApplicationUntilProxyStarts.
  • Jobs never finish. A classic sidecar outlives the job’s main container, so the pod never completes. Use native sidecars.
  • Retry storms. App retries 3x and mesh 3x per attempt: one failure becomes 9+ requests on a struggling service. Retry in one layer only, with a budget and backoff.
  • Timeouts must nest. A 1 s client timeout before a 2 s mesh timeout leaves the mesh working for nobody. Make outer timeouts longer than inner ones.
  • Protocol detection stalls. Meshes guess protocols, and server-first ones like MySQL and SMTP confuse it. Name Service ports (http-, grpc-, tcp-) or set appProtocol; declare server-first protocols as TCP.
  • Upgrades restart every pod. Sidecars update only on pod restart. Plan rolling restarts per namespace within the control plane’s version skew limits.
  • Control plane is tier-0. When down, existing proxies keep last config, but new pods get no certificates or config, and certificates expire within hours to days. Run it highly available; monitor certificate expiry.
  • Overhead adds up. Every request crosses two proxies: extra milliseconds per hop, tens of MB per sidecar, times every pod. Budget for it.
  • Long-lived connections don’t rebalance. An L7 proxy balances HTTP/2 and gRPC per request (an advantage), but raw TCP connections stick to one backend. Expect uneven load on raw TCP.
  • Envoy error codes are cryptic. In a 503, flag UF = upstream connection failed, UH = no healthy upstream, URX = retries exhausted. Learn the access log flags.
  • Egress is open by default. The mesh secures service-to-service traffic; calls to external APIs go straight out. Route them through an egress gateway and restrict outbound access.
  • Ambient L7 needs waypoints. Without one, ambient gives only mTLS and L4 authorization. Deploy a waypoint per namespace or service for HTTP routes, retries and L7 policy.
  • Cilium kube-proxy replacement bypasses sidecars. Without socketLB.hostNamespaceOnly: true, Istio sidecar routing gets bypassed. Set it - see Cilium vs kube-proxy.