Istio vs Linkerd vs Consul: What a Service Mesh Actually Enforces
I installed Istio, Linkerd and Consul on three identical Kubernetes clusters, ran the same two-service app on each, and pointed the same unauthorized pod at all three. Every one of them refused it. Every one of them then served it the same data through the endpoint it had quietly built for the kubelet’s health probe, with its own deny policy still in force.
That is the honest answer to “will a service mesh secure my cluster.” A mesh does three real things: it encrypts traffic between pods, it gives every workload a cryptographic identity, and it can refuse traffic based on that identity. What it does not do is draw the boundary where you assume it is. Everything below was measured on the same three clusters over one afternoon.
The lab, so you can weigh the numbers
Three kind clusters from one config file: two nodes, Kubernetes 1.35, plain kindnet, nothing else installed. Istio 1.30.3 (demo profile), Linkerd edge-26.7.2 (the project publishes no stable channel, so that is the current edge build), Consul 2.0.2 with TLS and ACLs on.
The app is deliberately dull: a backend serving one line of text, and a frontend that is a sleeping curl image. The text is the whole trick:
MESH-PLAINTEXT-MARKER-7f3a9c backend ok
Every encryption claim gets proved by running tcpdump inside the backend pod and grepping the capture for that marker. Present on the wire means plaintext. Absent, while requests still return 200, means the mesh encrypted it. No trust in documentation required.
A mesh is a proxy that takes your pod’s network away
Install one and your pod changes shape. Istio and Linkerd both inject the proxy as a native sidecar, an entry in initContainers carrying restartPolicy: Always. That is why kubectl get pod reports 2/2 while .spec.containers still lists only your app:
initContainer: istio-init restartPolicy=
initContainer: istio-proxy restartPolicy=Always
container: nginx
Consul is the outlier: consul-dataplane is an ordinary container. Either way an init container rewrites the pod’s iptables so nothing reaches your process without passing the proxy first, and that proxy holds a certificate naming the workload.
Those certificates are where the three designs show their history. Read straight off each running sidecar:
Istio spiffe://cluster.local/ns/shop/sa/default
Linkerd default.shop.serviceaccount.identity.linkerd.cluster.local (a DNS SAN)
Consul spiffe://<uuid>.consul/ns/default/dc/dc1/svc/frontend
Istio and Linkerd encode the same facts, namespace and service account and trust domain, one as a URI and one as a DNS name. Consul encodes something different: a datacenter, and a service name taken from the pod’s service account. Note what is missing. That ns/default is the Consul namespace, not the Kubernetes one, so the workload’s real namespace appears nowhere in its identity. Leaf certificates last 24 hours under Istio and Linkerd, and 72 hours under Consul.
Service discovery splits the same way. Istio and Linkerd read Kubernetes Services and are done. Consul keeps its own catalogue, so it demands two things the others do not: a dedicated ServiceAccount per service, because its ACL binding rule is literally serviceaccount.name!=default, and a Kubernetes Service for every mesh member, including a client that listens on nothing. Miss either and the pod sits in Init:0/1 looping on Permission denied.
Encryption was the easy part
All three passed, identically, on the first attempt:
| Mesh | Packets captured | Marker on the wire | Plaintext GET / |
|---|---|---|---|
| Istio | 40 | 0 | 0 |
| Linkerd | 40 | 0 | 0 |
| Consul | 52 | 0 | 0 |
One warning, because I got it wrong first. My initial Linkerd capture reported zero markers and I nearly believed it. Then I looked at the requests: every one in the window had failed with pods "frontend-..." not found, because I grabbed the pod name before the injection rollout finished. Zero markers with zero traffic proves nothing.
Only one of the three starts closed
Encryption being on does not mean encryption is required. I put an unmeshed pod in another namespace and had it call the meshed backend over plain HTTP:
| Mesh | Default inbound posture | Unmeshed pod’s result |
|---|---|---|
| Istio | PeerAuthentication PERMISSIVE |
200, read the marker |
| Linkerd | defaultInboundPolicy: all-unauthenticated |
200, read the marker |
| Consul | ACL default_policy: deny |
refused |
Two of the three offer mTLS rather than requiring it. Consul is closed, but the reason matters: that follows from enabling ACLs at install time, not from Consul being inherently stricter.
Turning on deny-by-default is a different object in each, and each fails in its own dialect. Worth memorising if you operate one:
| Denial | Istio | Linkerd | Consul |
|---|---|---|---|
| authorization | 403 RBAC: access denied |
403 |
curl exit 52 |
| transport-level mTLS | curl exit 56 (STRICT) |
not tested | curl exit 52 |
| unregistered egress host | 502 |
403 |
curl exit 56 |
| propagation after apply | ~20 s | immediate | under 5 s |
Istio’s lag is the one that will cost you an afternoon. Applying a deny-all and testing immediately gave me 200 403 403: the first request still succeeded roughly 20 seconds after the API server accepted the policy. It lags coming back, too. After I deleted every policy, requests kept returning 403 for a while. The API server accepting a policy does not mean the policy is in force.
All three leak the probe path past their own authorization
I did not go looking for this one. Every mesh has to keep the kubelet’s HTTP probe working after it seizes the pod’s network, because a proxy that breaks readiness is a proxy nobody ships. Each solves it differently, and in all three the solution is an unauthenticated opening the size of whatever the probe path returns.
My backend’s readiness probe was httpGet: { path: /, port: 80 }, which is an entirely ordinary choice. It is also the content itself.
Same test each time: an unmeshed pod in another namespace, calling the backend’s pod IP directly, with the mesh’s deny policy active. Every mesh refused it on the application’s ordinary paths. Every mesh then served it the marker through the probe endpoint:
| Mesh | Mechanism | The endpoint that answered 200 |
|---|---|---|
| Istio | probe rewritten to a proxy port | :15020/app-health/nginx/readyz |
| Linkerd | probe path auto-authorized, from 0.0.0.0/0 |
:80/, the app’s own port |
| Consul | probe rewritten to a proxy port | :20400/ |
Consul’s probe port even returns the app’s own etag and last-modified headers behind server: envoy, so it is genuinely proxying your content rather than answering with a canned health response. Istio’s held steady across 45 seconds of rechecking, so this is not propagation lag.
Linkerd is the only one with a knob that looks like a fix, probeNetworks, and it does not work. Narrowing it to the node subnet blocked the intruder for about 25 seconds, then the backend failed its own readiness probe and dropped out of its Service. The probe does not arrive from the node’s address. Two sources agree it comes from the pod-network gateway:
proxy log: Request denied ... route.name=probe client.ip=10.244.1.1
tcpdump: 10.244.1.1.40320 > 10.244.1.9.80 [SYN]
The only CIDR covering 10.244.1.1 is the pod CIDR, which covers every pod in the cluster. You cannot separate the kubelet from ordinary pods by source address.
The fix is the same in all three and it is not a mesh setting: give the app a probe path that returns nothing worth having. I verified it on Linkerd. Moving readiness to /healthz with the policy untouched took the unmeshed client from 200 to 403 on /, /healthz still answered 200, and the pod never restarted.
One controlled way out costs more than you think
The pitch that a mesh gives you a single outbound gateway, something like a NAT for your cluster, is real. It is also entirely opt-in. All three let my meshed pod reach the internet with a 200 straight out of the box.
Closing it takes a switch plus registration:
| Mesh | The switch | Objects to allow one host | Gateway pod? |
|---|---|---|---|
| Istio | outboundTrafficPolicy: REGISTRY_ONLY |
ServiceEntry + Gateway + DestinationRule + VirtualService | yes |
| Linkerd | an EgressNetwork with trafficPolicy: Deny |
EgressNetwork + HTTPRoute | no |
| Consul | transparentProxy.meshDestinationsOnly: true |
ServiceDefaults + TerminatingGateway + ServiceIntentions | yes |
Linkerd has the neatest design here. Its policy lives in proxies you already run, so the choke point is logical and there is no gateway to scale. Istio and Consul route through a real pod, which you can prove: after one request, the Consul gateway’s own counters moved from 1 to 2 on its mTLS listener.
Two traps. Istio’s own docs admit the egress gateway “cannot securely enforce” that traffic uses it, and one annotation proves it: sidecar.istio.io/inject: false let a pod inside the meshed namespace walk straight past the gateway. A NetworkPolicy is what turns that routing choke point into a boundary. Consul’s trap is per port. Registering an HTTP destination on port 80 gave the sidecar a port-80 route table holding only that one hostname, so in-cluster calls on port 80 began returning 404 from Envoy. Moving the destination to an unused port fixed it. That reproduced four times, including across a host reboot.
What each one costs to run
Measured with kubectl top on the same clusters running the same app, not read off the charts’ resource requests:
| Mesh | Control plane | Proxy per pod | Engine |
|---|---|---|---|
| Istio 1.30.3 | 11m / 89 Mi (incl. 2 gateways) | 6m / 27-28 Mi | Envoy |
| Linkerd edge-26.7.2 | 5m / 70 Mi | 1m / 3 Mi | Rust |
| Consul 2.0.2 | 29m / 89 Mi (incl. 1 gateway) | 5-7m / 22-27 Mi | Envoy |
The control planes are closer than the marketing suggests: istiod alone was 3m/37 Mi against Linkerd’s destination pod at 3m/36 Mi. The data plane is where the gap is real. Linkerd’s proxy uses about a ninth of the memory of the two Envoy-based meshes, and a quarter of what the nginx it fronts uses. At 500 pods that difference is roughly 12 Gi.
One operational note on Consul 2.0.2: its injector crash-looped 11 times in an hour, watching a RouteExtProc CRD its own chart does not install. That deployment also serves the admission webhooks, so while it is down every Consul config change is rejected with connection refused. Two of my own applies were refused that way, silently.
Pick on the default you can live with
All three genuinely deliver the four capabilities I tested: mutual TLS, workload identity, identity-based authorization, and a single controlled egress. What should decide it for you is the defaults and the footprint. Pick Linkerd if per-pod cost matters and you want a small surface. Pick Consul if you need a catalogue spanning more than Kubernetes and want inbound closed from the start. Pick Istio if you need its routing and extension surface, and you accept paying attention to propagation lag.
One thing I measured but did not weigh above, and it may outrank everything else in a procurement conversation:
| Istio | Linkerd | Consul | |
|---|---|---|---|
| Source licence | Apache-2.0 | Apache-2.0 | BUSL 1.1 |
| OSI open source | yes | yes | no |
| Free stable releases from the project | yes | no, since Feb 2024 | yes |
| Governance | CNCF | CNCF | single vendor, IBM |
Linkerd’s code is Apache-2.0, but the project has shipped only edge builds for over two years: the eight most recent releases are all tagged edge-*, and a supported stable channel means a Buoyant licence key, free for production only under fifty employees. Consul’s binaries are free and its licence is not open source. Istio’s catch is complexity rather than paperwork.
None of them is secure by being installed. Before you compare anything else, go find out what your readiness probes return, then curl that path from a pod that has no business talking to the service and see what comes back.
Part 2 removes the sidecar entirely: what Istio’s ambient mode fixes, and what it hides. The per-pod memory line goes to zero, the probe hole above closes, and a total outage starts passing for healthy.