Skip to content

Service Mesh on Kubernetes: Istio vs Linkerd vs Cilium

You have 40 microservices talking to each other in plaintext. One team wants a 5% canary, another is asking why checkout takes 300 ms, and security is demanding encryption in transit inside the cluster. You can solve this with libraries in every service (and then do it again in Go, Python and Java), or you can push that logic down into the network layer.

That is a service mesh. It is also the piece of infrastructure I have most often seen installed without anyone being able to name the problem it was solving. This guide compares the three serious options left standing in 2026 — and devotes an entire section to the most profitable question of all: when not to install one.

🎯 What problem it actually solves

A service mesh intercepts traffic between pods and applies policy without touching application code. Four capabilities justify its existence:

Automatic mTLS

Encryption and mutual authentication between all pods, with managed certificate rotation. Without a mesh, every service needs its own TLS and its own certificate management. It complements — does not replace — NetworkPolicies and RBAC.

L7 observability

Golden signal metrics (request rate, error rate, p50/p95/p99 latency) per source-destination pair, with no application instrumentation. A mesh sees HTTP status codes and routes; a NetworkPolicy only sees IPs and ports.

Traffic splitting

Send 5% of traffic to the new version. Without a mesh you do this by juggling Deployment replicas (terrible granularity: with 3 pods your minimum is 33%).

Retries, timeouts and circuit breaking

Budgeted retries, per-route timeouts and ejection of failing instances — applied consistently across every language in your stack.

What a service mesh does NOT solve

It will not fix a slow application, it does not replace an ingress load balancer, it does not remove the need for distributed traces with business context (the mesh propagates headers, but your app must forward them), and it does not compensate for badly-cut microservice boundaries.

🏗️ Architecture: sidecar vs sidecarless

The fundamental difference between the three options is where the proxy lives.

graph TB
    subgraph "Istio sidecar (classic)"
        A1[App v1] --- P1[Envoy sidecar]
        A2[App v2] --- P2[Envoy sidecar]
        P1 <-->|mTLS| P2
        P1 -.config.- IS[istiod]
        P2 -.config.- IS
    end

    subgraph "Istio ambient / Linkerd"
        B1[App v1] --> Z1[ztunnel / node micro-proxy]
        B2[App v2] --> Z2[ztunnel / node micro-proxy]
        Z1 <-->|mTLS| Z2
        Z1 --> WP[optional L7 waypoint]
    end

    subgraph "Cilium eBPF"
        C1[App v1] --> K1[eBPF in kernel]
        C2[App v2] --> K2[eBPF in kernel]
        K1 <-->|WireGuard/IPsec| K2
        K1 -.L7 only when needed.- EV[per-node Envoy]
    end

Istio — Envoy, two modes

  • Sidecar: an Envoy container injected into every pod. Maximum power (Envoy's full L7 filter chain), maximum cost: one proxy per pod.
  • Ambient mode: no sidecars. ztunnel (a per-node Rust agent) handles mTLS and L4; waypoint proxies (Envoy, per namespace or service) are deployed only where you need L7. This is Istio's answer to the sidecar tax.
  • Control plane: istiod.

Linkerd — Rust micro-proxy

  • Sidecar, but with a purpose-built proxy written in Rust (linkerd2-proxy), not Envoy. It deliberately does far less than Envoy: less surface area, less memory, less configuration to break.
  • No sprawling configuration CRDs: it uses standard Gateway API (HTTPRoute) for routing.
  • Control plane: linkerd-destination, linkerd-identity, linkerd-proxy-injector.

Cilium Service Mesh — eBPF first

  • The datapath is eBPF in the kernel: no proxy in the path for L3/L4. Cilium is already your CNI, so the mesh does not add a new layer.
  • Encryption via WireGuard or IPsec at node level (not per-workload-identity mTLS in the classic sense), plus SPIFFE/SPIRE-based mutual authentication for identity policies.
  • L7 (HTTP routing, Gateway API) is delegated to a per-node Envoy, activated only when policy requires it.

Cilium is not a complete service mesh

Cilium covers L3/L4, encryption, observability (Hubble) and Gateway API very well. But it has no equivalent to budgeted retries, outlier-detection circuit breaking, or in-mesh (pod-to-pod) traffic splitting at the level of Istio/Linkerd. Cilium's traffic splitting goes through Gateway API — that is, through the ingress gateway. If your use case is canarying between internal services, this matters.

🚀 Real installation

# Install Istio with the ambient profile
istioctl install --set profile=ambient --skip-confirmation

# Enroll a namespace in ambient (the CNI plugin will use ztunnel
# for new and restarted pods)
kubectl label namespace my-app istio.io/dataplane-mode=ambient

# Verify workloads are enrolled
istioctl ztunnel-config workloads

Istio — sidecar mode

istioctl install --skip-confirmation

# Enable sidecar injection per namespace
kubectl label --overwrite namespace my-app istio-injection=enabled

# Existing pods are NOT injected automatically: restart them
kubectl rollout restart deployment -n my-app

Migrating from sidecar to ambient

When moving a namespace to ambient you must remove the injection label (istio-injection or the revision label) in addition to adding istio.io/dataplane-mode=ambient. Leave both and you will get a sidecar and ztunnel at the same time.

Linkerd

# Check the CLI (server version appears after the control plane is installed)
linkerd version

# 1) CRDs, 2) control plane
linkerd install --crds | kubectl apply -f -
linkerd install | kubectl apply -f -

# Validate the full installation
linkerd check

Namespace injection via annotation:

apiVersion: v1
kind: Namespace
metadata:
  name: my-app
  annotations:
    linkerd.io/inject: enabled

Cilium Service Mesh

# Gateway API (requires kube-proxy replacement)
cilium install \
    --set kubeProxyReplacement=true \
    --set gatewayAPI.enabled=true

# SPIRE-based mutual authentication
cilium install \
    --set authentication.mutual.spire.enabled=true \
    --set authentication.mutual.spire.install.enabled=true

# L7 load balancing with Envoy
cilium install \
    --set kubeProxyReplacement=true \
    --set envoyConfig.enabled=true \
    --set loadBalancer.l7.backend=envoy

# Observability
cilium hubble enable

🔐 mTLS and authorization policies

Istio

Strict mTLS across a namespace:

apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: my-app
spec:
  mtls:
    mode: STRICT

Namespace isolation (accept traffic only from the same namespace):

apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: my-app-isolation
  namespace: my-app
spec:
  action: ALLOW
  rules:
    - from:
        - source:
            namespaces: ["my-app"]

Migrate to STRICT via PERMISSIVE

Applying STRICT directly to a namespace that has non-meshed clients cuts traffic instantly. PERMISSIVE mode (the default) accepts both, lets you confirm in metrics that everything is encrypted, and only then do you tighten.

Linkerd

mTLS between injected pods is on by default, with no configuration. Policies are expressed per route:

apiVersion: policy.linkerd.io/v1alpha1
kind: AuthorizationPolicy
metadata:
  name: authors-get-policy
  namespace: booksapp
spec:
  targetRef:
    group: policy.linkerd.io
    kind: HTTPRoute
    name: authors-get-route
  requiredAuthenticationRefs:
    - name: authors-get-authn
      kind: MeshTLSAuthentication
      group: policy.linkerd.io
---
apiVersion: policy.linkerd.io/v1alpha1
kind: MeshTLSAuthentication
metadata:
  name: authors-get-authn
  namespace: booksapp
spec:
  identities:
    - "books.booksapp.serviceaccount.identity.linkerd.cluster.local"
    - "webapp.booksapp.serviceaccount.identity.linkerd.cluster.local"

Cilium

Mutual authentication is enabled inside a CiliumNetworkPolicy, not as a separate resource:

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: api-requires-auth
  namespace: my-app
spec:
  endpointSelector:
    matchLabels:
      app: api
  ingress:
    - fromEndpoints:
        - matchLabels:
            app: frontend
      authentication:
        mode: "required"
      toPorts:
        - ports:
            - port: "8080"
              protocol: TCP
          rules:
            http:
              - method: "GET"
                path: "/api/v1/.*"

With mode: "required", Cilium only allows the connection if both workloads have mutually authenticated via SPIRE.

🔀 Canary and traffic splitting

Istio — weighted VirtualService

apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: reviews-route
spec:
  hosts:
    - reviews.prod.svc.cluster.local
  http:
    - route:
        - destination:
            host: reviews.prod.svc.cluster.local
            subset: v1
          weight: 75
        - destination:
            host: reviews.prod.svc.cluster.local
            subset: v2
          weight: 25

Linkerd — Gateway API HTTPRoute

apiVersion: policy.linkerd.io/v1beta2
kind: HTTPRoute
metadata:
  name: bb-route
  namespace: traffic-shift-demo
spec:
  parentRefs:
    - name: bb
      kind: Service
      group: core
      port: 8080
  rules:
    - backendRefs:
        - name: bb
          port: 8080
          weight: 90
        - name: bb-v2
          port: 8080
          weight: 10

Cilium — Gateway API at the edge

apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
  name: echo-route
spec:
  parentRefs:
    - name: cilium-gw
  hostnames:
    - "*"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /echo
      backendRefs:
        - name: echo-1
          port: 8080
          weight: 99
        - name: echo-2
          port: 8090
          weight: 1

Do not shift traffic by hand

Hand-editing weights in production is how Friday-afternoon incidents happen. Both Istio and Linkerd integrate with Flagger, which promotes the canary automatically based on metrics (success rate, p99 latency) and rolls back on its own if they degrade.

📊 Comparison

Aspect Istio (ambient) Istio (sidecar) Linkerd Cilium
Datapath ztunnel (Rust) + Envoy waypoint Envoy per pod Rust micro-proxy per pod eBPF + per-node Envoy
Per-workload mTLS ✅ (by default) ⚠️ SPIFFE auth + WireGuard/IPsec
Internal traffic split ❌ gateway only
Retries / circuit breaking ✅ (waypoint) ✅ full ✅ basic
Observability Prometheus + Kiali Prometheus + Kiali linkerd viz Hubble
Multi-cluster ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ Cluster Mesh
Config surface High Very high Low Medium
Learning curve ⭐⭐ hard ⭐ very hard ⭐⭐⭐⭐⭐ gentle ⭐⭐⭐ (assumes Cilium CNI)
CNI requirement Any Any Any Cilium mandatory
Governance CNCF Graduated CNCF Graduated CNCF Graduated CNCF Graduated

⚡ Overhead: orders of magnitude, not promises

About these figures

The figures below are orders of magnitude drawn from benchmarks published by the projects themselves and from the CNCF Service Mesh Performance work. They are not our own measurements. Overhead depends heavily on RPS, payload size, whether L7 filters are active, and node CPU. Measure in your own cluster before deciding.

Model Added latency (p99, order) CPU/memory per pod (order)
Istio sidecar (Envoy) Single-digit ms (~2-5 ms) ~50-100 MB and tens of millicores per pod
Istio ambient (ztunnel only, L4) Sub-millisecond Cost per node, not per pod
Istio ambient + L7 waypoint Single-digit ms on the L7 hop Cost per namespace/service
Linkerd micro-proxy Sub-millisecond to ~1 ms ~10-20 MB per pod
Cilium eBPF (L4) Practically nil (no proxy) Cost per node (agent)
Cilium with L7 Envoy Single-digit ms on L7 flows Cost per node

The structural conclusions (which are reliable, regardless of the exact numbers):

  1. The per-pod model scales badly. 1,000 pods with an Envoy sidecar means 1,000 proxies with their baseline memory. Ambient and eBPF move the unit of cost from pod to node.
  2. L7 costs. Parsing HTTP always costs more than forwarding bytes. If you only need mTLS and L4, don't pay for L7.
  3. Added latency is rarely the bottleneck. Resource consumption and operational load usually are.

🔭 Observability

All three export metrics to Prometheus and fit the site's observability stack.

# Istio: Kiali dashboard (topology + live traffic)
istioctl dashboard kiali

# Linkerd: viz extension, live golden signal metrics
linkerd viz install | kubectl apply -f -
linkerd viz dashboard

# Cilium: L3/L4/L7 flows with Hubble
cilium hubble enable
hubble observe --namespace my-app --protocol http

Metrics != traces

No mesh produces complete distributed traces on its own. It propagates tracing headers, but if your application does not forward the traceparent / b3 headers from inbound to outbound requests, your traces will come out broken. That work belongs to the app, not the mesh.

🛑 When you do NOT need a service mesh

The most important section of this guide. A service mesh is a distributed network-configuration database sitting in front of all your production traffic — and when it fails, it fails across every service at once.

Do not install one if:

  • You have fewer than ~10 services. With 5 services you know every call from memory. The mesh adds more operational complexity than it removes.
  • Your whole stack is one language. If it is all Go or all Java, a shared library (or Spring Cloud, or gRPC interceptors) gives you retries, timeouts and mTLS with vastly less machinery.
  • Your real problem is ingress, not east-west. If all you need is TLS termination and host/path routing, an Ingress Controller or Gateway API is enough. A mesh is for traffic between services.
  • Your encryption requirement is "encryption in transit", without per-workload identity. CNI-level WireGuard (Cilium, Calico) covers it at a fraction of the cost. If the auditor demands cryptographic identity per service, then yes, you need mesh mTLS.
  • Your segmentation is solved by NetworkPolicies. "Frontend only talks to API" is a NetworkPolicy, not a mesh.
  • You have no platform team. A mesh needs someone who understands its data model, tracks its upgrades and can debug it at 3am. Without that owner, a mesh is technical debt with a pretty dashboard.
  • You don't have basic metrics yet. If you don't even have Prometheus with application metrics, a mesh is building the roof before the walls.

The hidden cost: debugging

With a mesh, a 503 can come from the app, the source proxy, the destination proxy, a policy timeout, an expired certificate, or a badly-written AuthorizationPolicy. The debugging surface multiplies. You pay that cost at every incident, not just on installation day.

🧭 Decision guide

  • You already run Cilium as your CNI and only want mTLS + L4/L7 observability and Gateway APICilium Service Mesh. Zero new components, near-zero cost.
  • You want mTLS and golden signals tomorrow, with the fewest new conceptsLinkerd. The mesh you install in an afternoon and that doesn't wake you up at night.
  • You need complex L7 routing, multi-cluster, fine-grained authorization policies, or WASM extensionIstio in ambient mode. All the power without the per-pod sidecar tax.
  • You have a large investment in custom Envoy filters or EnvoyFilter resourcesIstio sidecar, until ambient covers your case.
  • None of the above describes you → you probably don't need a service mesh yet. Come back when you have the problem.

Official documentation