← Projects
Networking

Service Mesh Evolution: Istio Sidecars → Cilium eBPF

Blue-green migrated a 300-node production mesh off Istio sidecars onto a Cilium eBPF dataplane, unifying CNI and mesh, cutting API latency ~5%, and stretching one fabric across two clouds.

300 nodes
blue-green migrated, old mesh live throughout
~5%
API latency drop, sidecars removed
L4 in eBPF
L7 on a node-local Envoy
EKS + OKE
one fabric via Cilium ClusterMesh

Choosing a service mesh is a decision you should be willing to revisit as the ecosystem moves. I did: moving a 300-node production cluster off a full Istio stack onto Cilium's eBPF dataplane. Because swapping the CNI and the mesh at once is inherently unpredictable, I did it blue-green: stand up a brand-new cluster on the target stack and cut traffic over once it proved itself, with the old mesh serving live traffic the entire time.

Why Istio first, and what we were leaving

I started on Istio in sidecar mode because I needed L7 authorization policies the Gateway API didn't yet support. It was well-tested and did the job. Over time it grew into a full stack: sidecar Envoys handling both L4 and L7, the Istio CNI plugin wiring their traffic redirection, Kiali for mesh observability, and Istio Multi-Cluster joining our clusters, all driven by Istio's native VirtualService and Gateway resources. It worked. The costs were the latency every sidecar added to every hop, and the sheer number of moving parts.

The target stack replaced each of those pieces with a Cilium-native one. Here is the whole swap at a glance:

One cluster's stack, swapped layer by layer

Every piece of the Istio stack had a Cilium-native replacement. Tap a layer to see the trade.

Before · Istio
After · Cilium
L4 + L7 traffic

Istio puts a full Envoy next to every pod to handle both layers. Cilium keeps L4 in the kernel with eBPF and only reaches for a single per-node Envoy when an L7 policy actually applies. Fewer proxies, fewer hops.

Every layer of the Istio stack mapped to a Cilium replacement. Tap any row for the trade-off behind it.

L4 in the kernel, L7 only when you need it

The core difference is where traffic is handled. Istio injects a full Envoy sidecar into every pod, and that proxy terminates both L4 and L7, so every hop pays two userspace Envoy traversals, one on each end, whether or not any L7 rule is in play. Cilium splits the layers: eBPF enforces L4 forwarding and policy in the kernel, and L7 is handled by a single node-local Envoy that the Cilium agent starts per node, which only sees traffic that actually matches an L7 policy.

That is the whole latency story. One shared node-local proxy costs less than a sidecar on both ends, and pure L4 traffic never leaves the kernel for userspace at all, which is where our roughly 5% API-latency drop came from. Watch a single request travel each way:

How a request actually travels, pod to pod

Same two pods, same request. The difference is how many userspace proxies it has to cross.

Pod AeBPF · L4 in kernelnode Envoy · L7 only if a policy matcheseBPF · L4 in kernelPod B

L4 forwarding and policy stay in the kernel; traffic only detours to a single node-local Envoy when an L7 rule actually matches. Most packets never touch userspace at all.

Net effect: one node-local Envoy costs less latency than the two-sidecar model, and L4-only traffic pays nothing for a proxy at all. That is where our ~5% API-latency drop came from.
Istio terminates every hop in two sidecar Envoys; Cilium keeps L4 in eBPF and detours to one node Envoy only for L7.
What the eBPF dataplane unlocked next
The same kernel foundation that carries our traffic made Tetragon the obvious runtime-security move, and the feasibility gap that made us defer it.

The CNI switch was mostly a deletion

People expect the CNI change to be the scary part. It was the opposite. The Istio CNI plugin is not a full CNI. It chains onto your real one and exists only to set up sidecar traffic redirection without granting NET_ADMIN to every pod. Once there are no sidecars, there is nothing to redirect to, so that entire plugin simply goes away.

Cilium, by contrast, is a full eBPF CNI: on the new cluster it owns pod networking, IPAM, and L4 policy natively. So the move wasn't porting one CNI config to another. It was deleting a category of configuration and letting the kernel datapath do the job the sidecar redirection used to.

Observability: Kiali to Hubble

Kiali is a genuinely good Istio console. It draws the mesh graph and validates how VirtualServices and DestinationRules wire together. But it builds that picture from Envoy telemetry scraped into Prometheus, so it only ever sees what the sidecars measured. When the sidecars left, so did its data source.

Hubble reads flow events straight from eBPF in the kernel. It sees every flow on the node (meshed or not, L3/L4 and L7 alike) and, crucially, it shows the policy verdict behind each one: when a connection is dropped, it names the exact policy responsible. That is a different and, for us, more useful lens:

Kiali and Hubble, side by side

Both draw a service map. They are looking at completely different things to do it. Pick a dimension.

KialiEnvoy metrics

Shows what flowed. It does not tell you why a given connection was denied.

HubbleeBPF flows

Shows the verdict for every flow, including which network policy dropped it and why.

The honest read: Kiali is the better Istio config console. Hubble is the better flow-and-security lens, which is exactly what we needed once the sidecars, and the metrics they emitted, were gone.
Same service map, different source of truth. Kiali reads Envoy metrics; Hubble reads kernel flows and policy verdicts.

One fabric across two clouds

We had joined our clusters with Istio Multi-Cluster, which routes cross-cluster traffic through an East-West gateway and loads remote endpoints into every sidecar. On the new stack we used Cilium ClusterMesh instead: it shares endpoint state in the kernel via clustermesh-apiserver and routes pod-to-pod directly, with any Service annotated global load-balancing and failing over across clusters on its own.

This is how we connected the primary EKS cluster to a burst cluster on Oracle OKE running some legacy workloads, one logical service fabric across two clouds, discoverable and policy-enforced on both sides. Istio has the richer cross-cluster L7 story; we valued the leaner, kernel-level connectivity and the far smaller pile of moving parts:

Istio Multi-Cluster vs Cilium ClusterMesh

Two ways to make many clusters behave like one fabric. We joined EKS and Oracle OKE, pick a dimension.

Istio Multi-Clustergateway + sidecars

Traffic to another cluster hairpins through an East-West gateway (an Envoy) in each cluster.

Cilium ClusterMesheBPF datapath

Pods route to remote pods directly at the eBPF datapath, with no central gateway hop in the middle.

Why we picked ClusterMesh: Istio has the richer cross-cluster L7 story, but for a burst fabric joining EKS to a legacy OKE cluster we valued kernel-level connectivity, global-service failover, and far fewer moving parts.
Istio hairpins through an East-West gateway and fattens every sidecar; ClusterMesh routes in the kernel with global services.
The mTLS that wasn't
ClusterMesh is also why we can't yet adopt Cilium's real native mTLS, and how "mTLS-based" turned out to mean authentication, not encryption.

The network underneath the mesh

ClusterMesh is only the Kubernetes layer of that cross-cloud link. Underneath it sits a real hybrid network: an IPSec site-to-site VPN between AWS and OCI, Transit Gateways stitching each region together, and two fully independent AWS regions fronted by Route 53 and Global Accelerator for disaster recovery. The mesh rides on that reachability. It doesn't create it. I built and wrote about the whole fabric separately.

The multi-cloud, multi-region network this mesh rides on
EKS across two independent AWS regions plus an Oracle OKE burst cluster, joined by an IPSec VPN, with ClusterMesh scoped to the primary only.

Why the Gateway API

The last piece was routing. Istio's VirtualService and Gateway are powerful but not portable. They are Istio CRDs, and they do not survive a change of mesh. Moving our north-south routing to the Kubernetes Gateway API meant that config was vendor-neutral: the same routes run on Istio or Cilium, so they carried across the migration instead of being rewritten from scratch.

The Gateway API also has a cleaner shape. It splits one tangled config into three resources with three clear owners (GatewayClass, Gateway, and HTTPRoute) and gives each protocol its own typed resource instead of cramming everything into a single VirtualService:

Why the Gateway API, not more VirtualServices

The Gateway API splits one tangled config into three resources with three clear owners. Tap a layer.

HTTPRoute· owned by the application developer

Attaches routing to a Gateway: hostnames, paths, header matches, backends, weights. The developer ships routes without ever touching listener or infrastructure config.

Portable by design

The Gateway API is a vendor-neutral upstream standard, so the same routes run on Istio or Cilium. That portability is a big part of why our north-south config survived the mesh swap instead of being rewritten.

One resource per protocol

HTTPRoute, GRPCRoute, TCPRoute, TLSRoute: each protocol is its own typed resource, instead of an Istio VirtualService that crams every protocol into one object.

Role-oriented and portable: infra owns the GatewayClass, operators own Gateways, developers own routes, on any conformant implementation.

Why we did it blue-green

Swapping the CNI and the mesh in one move is exactly the kind of change that behaves unpredictably in ways no staging cluster fully reproduces. So rather than mutate a live 300-node cluster in place, I stood up a fresh cluster on the entire target stack (Cilium CNI, eBPF L4, node Envoy L7, Hubble, ClusterMesh, and Gateway API) and let the old Istio cluster keep serving production the whole time. We validated the new fabric under real conditions, cut traffic over with a network flip, and kept the old cluster warm as an instant rollback path.

The upgrade we batched into this cutover
The same blue-green window carried a six-version Kubernetes leap, 1.30 → 1.36. How it went, and the deadline that forced it.
Stack
CiliumeBPFNode EnvoyHubbleGateway APICilium ClusterMeshIstio (prior)Kiali (prior)