Cilium Service Mesh: The Sidecar-Free eBPF Mesh on Kubernetes 1.37

Sources

Cilium is the default answer today when platform teams list their CNI requirements: eBPF datapath, kube-proxy replacement, identity-based network policy, and Hubble flow visibility without touching a single application container. But the pitch usually stops there. The interesting question for a platform engineer in late 2026 is no longer "which CNI?" — it is "do we still need a separate service mesh, or can the CNI absorb it?"

Hubble service map showing flows between namespaces in a Kubernetes cluster
Hubble's service map: L7 flows between namespaces, visualized from eBPF telemetry. Screenshot from Cilium docs (Apache-2.0).

This guide takes the second question seriously. We walk the full sidecar-free mesh stack you can assemble with Cilium 1.20 on Kubernetes 1.37: the eBPF datapath for L3/L4, the per-node Envoy for L7, ztunnel for in-path mTLS, Gateway API v1.6 for north-south traffic, Hubble for observability, and ClusterMesh for multi-cluster. Every manifest here is complete and runnable. Nothing is a sketch.

We also spend a lot of time on the parts vendor blogs skip: what breaks when you delete kube-proxy, why L7 policy and ztunnel mTLS fight each other, the exact upgrade sequence for Gateway API CRDs, and which workloads should never enroll in the mesh. By the end you should be able to decide — with numbers and commands, not vibes — whether Cilium Service Mesh replaces the sidecar tax in your platform, or whether you genuinely still need Istio's waypoint model.

TL;DR

Why Sidecars Lost the Argument

The sidecar model charged every pod a fixed tax: CPU shares, memory floor (~50–100 MB per proxy), startup coupling, and an upgrade campaign every time the mesh control plane revved. The last one is the killer at fleet scale — a sidecar mesh upgrade means a rolling restart of every pod in the fleet, coordinated with HPA churn and deployment windows. An eBPF mesh upgrades the DaemonSet, and the fleet keeps serving.

Cilium's answer is architectural, not cosmetic. Instead of putting a proxy next to every workload, it puts the datapath functions under them:

The result is that the "mesh" is no longer a thing you install next to your platform. It is a set of capabilities your CNI already ships, and a set of CRDs you apply selectively. The rest of this guide is the operating manual for those capabilities.

Architecture: What Runs Where

Before manifests, get the component map right. A full Cilium Service Mesh installation on Kubernetes 1.37 consists of:

The Four Planes

  • Agents (DaemonSet cilium): one per node; compiles and attaches eBPF programs, enforces policy, programs service load balancing (BPF LB / Maglev), and redirects L7-matched flows to the node-local Envoy via TPROXY.
  • Operator (Deployment cilium-operator): IPAM, Node Init, ClusterMesh API server management, BGP control plane reconciliation, and (in 1.20) the ztunnel enrollment reconciler that registers SPIFFE identities with SPIRE in spire CA mode.
  • Envoy (DaemonSet cilium-envoy): the per-node L7 engine. Ingress, Gateway API, and L7 CiliumNetworkPolicy rules all compile down to xDS config streamed to this shared proxy. One process per node, not one per pod.
  • Hubble (in-agent) + Relay (Deployment) + UI: flow observation on every node, aggregated by a single relay, queryable by CLI or the Hubble UI deployed as its own chart.

And the data path a packet actually takes:

  Pod A (source)                       Pod B (destination)
 ┌──────────────┐                     ┌──────────────┐
 │  app container│                    │  app container│
 └──────┬───────┘                     └──────▲───────┘
        │ send()                             │ deliver()
 ┌──────▼───────────────────────────────────┴───────┐
 │  Pod A's netns (veth/netkit)                     │
 │   ├─ eBPF (from-container): policy check L3/L4   │
 │   └─ L7 rule matched? ── TPROXY ──► Envoy (node) │
 │                                      │ xDS route │
 │                                      ▼           │
 │  Node kernel: BPF LB / Maglev pick backend       │
 │   ├─ same node: deliver to Pod B netns directly  │
 │   └─ cross node: encapsulate (VXLAN/GENEVE) or   │
 │      route (native), [optional IPsec/WG, or     │
 │      ztunnel HBONE 15008 for mTLS]               │
 └──────────────────────────────────────────────────┘

Three things to internalize from this diagram, because they drive every trade-off later:

  1. The kernel path is the default. Only flows explicitly selected by an L7 rule take the Envoy hop. Everything else is wire-speed in-kernel forwarding.
  2. Envoy is per node, so L7 capacity is per node. A single hot pod with 20k RPS of HTTP policy does not get its own proxy; it shares the node's Envoy with every other L7-matched flow on that node. Capacity-plan Envoy DaemonSet resources accordingly.
  3. ztunnel changes the picture at the pod netns edge. When a namespace is enrolled, iptables rules inside each pod's network namespace redirect supported TCP flows to the node-local ztunnel, which tunnels them via HBONE (mTLS over HTTP/2 CONNECT, port 15008). This is why ztunnel mTLS and L4/L7 policy interact in non-obvious ways — policy that used to see the real 5-tuple now sees the tunnel.

Prerequisites & System Requirements

Cilium 1.20 requires Linux kernel >= 5.10 (or the equivalent backport, e.g. 4.18 on RHEL 8.10), AMD64 or AArch64 nodes, and iptables/netfilter kernel options present for masquerading, tunneling, and the L7/FQDN proxy features. Managed offerings lower the bar further: GKE with the Cilium datapath, EKS with the AmazonLinux 2/Bottlerocket AMIs, AKS with Azure CNI powered by Cilium — each ships a kernel that meets the requirements.

Verify before you install:

# Kernel and arch
uname -r            # expect >= 5.10, or 4.18 on RHEL 8.10
uname -m            # x86_64 or aarch64

# Required netfilter options (module names per docs.cilium.io System Requirements)
for opt in CONFIG_NETFILTER_XT_SET CONFIG_NETFILTER_XT_MATCH_COMMENT \
           CONFIG_NETFILTER_XT_TARGET_MARK CONFIG_NETFILTER_XT_TARGET_TPROXY; do
  grep -q "$opt=[my]" "/boot/config-$(uname -r)" && echo "$opt OK" || echo "$opt MISSING"
done

# Kubernetes version context (guide targets 1.37)
kubectl version --short 2>/dev/null || kubectl version --output=yaml | head -6

Kernel gotcha: the container image ships its own clang+LLVM >= 18.1 toolchain for runtime compilation where needed, so you do not need clang on the host — but the kernel config options above are non-negotiable. On minimal node images (Flatcar, custom Talos configs), verify TPROXY support specifically: with the default bpf.tproxy=false, L7 redirection uses iptables TPROXY, and nodes missing those modules will silently blackhole Gateway API traffic that "should work."

Reference Installation: Helm Values for a Mesh-Ready Cluster

The Cilium Helm chart is the source of truth for a production install. The values below enable the full mesh stack on a fresh cluster: kube-proxy replacement, L7 Envoy, Hubble with Relay, Gateway API controller, and ztunnel mTLS. Apply it with the 1.20.2 chart:

# cilium-values.yaml — Cilium 1.20.2, mesh-ready reference install
kubeProxyReplacement: true          # replace kube-proxy with BPF LB
k8sServiceHost: <API_SERVER_IP>    # required when kube-proxy is removed
k8sServicePort: 6443

# --- Datapath ---
# bpf.datapathMode=auto probes each host for netkit (kernel >= 6.8)
# and falls back to veth on the rest of a mixed fleet (new in 1.20)
bpf:
  datapathMode: auto               # default remains "veth"
  tproxy: false                    # default: iptables-based TPROXY for the L7 proxy

routingMode: tunnel                # or "native" with direct routing
operator:
  replicas: 2

# --- L7 / Envoy ---
l7Proxy: true                      # required for L7 policy + Ingress + Gateway API
envoy:
  enabled: true                    # dedicated cilium-envoy DaemonSet

# --- Gateway API ---
gatewayAPI:
  enabled: true                    # requires Gateway API CRDs >= v1.6.1 (see below)

# --- Hubble ---
hubble:
  enabled: true
  relay:
    enabled: true
  metrics:
    enabled:
      - dns
      - drop
      - tcp
      - flow
      - icmp
      - http

# --- ztunnel mTLS (Beta in 1.20) ---
encryption:
  enabled: true
  type: ztunnel                    # was "ipsec" or "wireguard" for transparent-only
  ztunnel:
    ca:
      type: internal               # 'spire' for the SPIFFE/SPIRE identity stack

Two values here are capacity decisions, not defaults. operator.replicas should be 2+ in any cluster you care about (the operator runs the ztunnel enrollment reconciler and IPAM; one replica is a SPOF). And because Envoy is shared per node, size the Envoy DaemonSet's resources against your real per-node L7 RPS rather than shipping the chart defaults — the node-local proxy is now the L7 budget for every pod scheduled on that node.

Install:

# Gateway API CRDs FIRST — Cilium 1.20 requires v1.6.1 minimum.
# Standard channel first:
kubectl apply --server-side -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.6.1/config/crd/standard/gateway.networking.k8s.io_gatewayclasses.yaml
kubectl apply --server-side -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.6.1/config/crd/standard/gateway.networking.k8s.io_gateways.yaml
kubectl apply --server-side -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.6.1/config/crd/standard/gateway.networking.k8s.io_httproutes.yaml
kubectl apply --server-side -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.6.1/config/crd/standard/gateway.networking.k8s.io_referencegrants.yaml
kubectl apply --server-side -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.6.1/config/crd/standard/gateway.networking.k8s.io_grpcroutes.yaml
kubectl apply --server-side -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.6.1/config/crd/standard/gateway.networking.k8s.io_backendtlspolicies.yaml
kubectl apply --server-side -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.6.1/config/crd/standard/gateway.networking.k8s.io_tlsroutes.yaml
# Experimental channel (ListenerSets, TCPRoute/UDPRoute in this cycle):
kubectl apply --server-side -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.6.1/config/crd/experimental/gateway.networking.k8s.io_listenersets.yaml
kubectl apply --server-side -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.6.1/config/crd/experimental/gateway.networking.k8s.io_tcproutes.yaml
kubectl apply --server-side -f https://raw.githubusercontent.com/kubernetes-sigs/gateway-api/v1.6.1/config/crd/experimental/gateway.networking.k8s.io_udproutes.yaml

# Then Cilium itself:
helm upgrade --install cilium cilium/cilium --version 1.20.2 \
  --namespace kube-system \
  --values cilium-values.yaml --wait

# Generate and apply the ztunnel CA/bootstrap secrets (see next section for full script)
bash generate-ztunnel-secrets.sh

# Confirm the stack:
cilium status --wait
cilium hubble enable   # no-op if Helm already enabled it; patches ConfigMap if needed

Do not skip the secrets step with ztunnel. The ztunnel integration requires a pre-created Kubernetes secret (cilium-ztunnel-secrets) containing bootstrap and CA keys before the agents will come up. This follows the same injection pattern as IPsec key rotation, and unlike the chart's certgen mode used for Hubble/ClusterMesh certs, it is on you to create them. The full generation script is below.

#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0
set -eu

workdir=$(mktemp -d)
cd "$workdir"

# == Bootstrap identity (secures ztunnel <-> Cilium xDS/cert server) ==
openssl genrsa -out bootstrap-private.key 2048

cat > openssl.conf <<'EOF'
[ req ]
distinguished_name = req_distinguished_name
x509_extensions = v3_ca
prompt = no

[ req_distinguished_name ]
O = cluster.local

[ v3_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth, clientAuth
subjectAltName = @alt_names

[alt_names]
DNS.1 = localhost
EOF

openssl req -x509 -new -nodes -key bootstrap-private.key -sha256 -days 3650 \
  -out bootstrap-root.crt -config openssl.conf

# == CA (signs ephemeral in-memory workload certs issued to ztunnel) ==
openssl genrsa -out ca-private.key 2048

cat > ca-openssl.conf <<'EOF'
[ req ]
distinguished_name = req_distinguished_name
x509_extensions = v3_ca
prompt = no

[ req_distinguished_name ]
O = cluster.local

[ v3_ca ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always,issuer
basicConstraints = critical, CA:true
keyUsage = critical, digitalSignature, cRLSign, keyCertSign
EOF

openssl req -x509 -new -nodes -key ca-private.key -sha256 -days 3650 \
  -out ca-root.crt -config ca-openssl.conf

kubectl --namespace kube-system create secret generic cilium-ztunnel-secrets \
  --from-file=bootstrap-private.key=bootstrap-private.key \
  --from-file=bootstrap-root.crt=bootstrap-root.crt \
  --from-file=ca-private.key=ca-private.key \
  --from-file=ca-root.crt=ca-root.crt

echo "ztunnel secrets created in kube-system/cilium-ztunnel-secrets"

The Control Plane Is Not One Thing: Istio Ambient vs Cilium Mesh

Both Istio ambient and Cilium Service Mesh run a per-node proxy named ztunnel. That is not a coincidence — Cilium 1.19+ builds on the upstream ztunnel project that originated in Istio's ambient work, with joint engineering between Isovalent and Microsoft. The difference is what the rest of the stack looks like around that proxy, and it matters for your migration path.

DimensionCilium Mesh (1.20)Istio Ambient
L4 datapatheBPF in-kernel; ztunnel for HBONE mTLS on enrolled namespaces (Beta)ztunnel per node (Rust); all in-mesh TCP via HBONE
L7 datapathPer-node Envoy, used only for L7-selected flowsOptional waypoint proxies (Envoy deployments), destination-enforced
Control planeCilium agents + operator; xDS to node Envoyistiod; separate xDS streams to ztunnel and waypoints
mTLS scopeNamespace label opt-in (io.cilium/mtls-enabled=true); TCP only; in-cluster onlyNamespace label (istio.io/dataplane-mode=ambient); TCP; plaintext fallback policy-controlled
L7 policy APICiliumNetworkPolicy L7 rules; upstream KNP/KCNP tiersAuthorizationPolicy (L4 at ztunnel, L7 at waypoint)
North-southGateway API (HTTP, gRPC, TLS, TCP, UDP routes), Ingress controllerIngress Gateways + Gateway API; waypoint-mediated ingress
Multi-clusterClusterMesh (Global Services, MCS-API stable in 1.20) — incompatible with ztunnel todayEast-west and multi-network gateways; no ClusterMesh equivalent
Day-2 upgradesDaemonSet rollout; L7 flows th ... [OUTPUT TRUNCATED - 744 chars omitted out of 50,674 total] ... it containers delay pod readinessNone — pods never wait for a proxy
Per-pod added latencyTwo extra hops (sidecar in/out)Zero for L3/L4; one hop only for L7-selected flows

The honest caveat: the node-local model moves the capacity conversation from per pod to per node. A node running 30 L7-heavy pods needs an Envoy sized for the sum of their RPS. Right-size the DaemonSet resources (see the Helm values above) and autoscale nodes on Envoy CPU, and the model still wins by an order of magnitude — but "it's the CNI's problem now" is not a capacity plan.

Upgrade Runbook: 1.19 → 1.20

Cilium supports upgrades between consecutive minor versions only, and expects the latest patch of your current minor first. The runbook that avoids the known traps:

# 0. Preflight — do these BEFORE touching Helm
#    a) Kafka/L7 Go-extension policy blocks? (removed in 1.18, will hard-fail)
grep -rn "rules:" policies/ | grep -E "kafka|l7proto" && echo "CLEAN FIRST"
#    b) CiliumNodeConfig on v2alpha1? (removed in 1.20)
kubectl get ciliumnodeconfigs.cilium.io -A -o jsonpath='{range .items[*]}{.apiVersion}{"
"}{end}' | sort -u
#    c) TLSRoute users: back up now
kubectl get tlsroutes.gateway.networking.k8s.io -A -o yaml > tlsroutes-backup.yaml

# 1. Latest patch of current minor
helm upgrade cilium cilium/cilium --version 1.19.7 -n kube-system --reuse-values --wait

# 2. Gateway API CRDs to v1.6.1 — Experimental channel for TLSRoute v1alpha2
#    (see full CRD commands in the installation section above)

# 3. Cilium 1.20.2
helm upgrade cilium cilium/cilium --version 1.20.2 \
  -n kube-system --reuse-values --wait

# 4. Post-upgrade verification
cilium status --wait
cilium connectivity test --force
kubectl -n kube-system rollout status ds/cilium ds/cilium-envoy
hubble observe --since 10m --verdict DROPPED | head   # watch for surprise drops

# 5. Watch the config-drift metric 1.20 added
#    cilium_configDrift* — keys the agent hasn't applied yet = restart needed
kubectl -n kube-system exec ds/cilium -- cilium-dbg metrics list | grep -i drift

Deprecated in 1.20 — schedule the migration now: Mutual Authentication (the pre-ztunnel feature, Beta since 1.14) is deprecated with removal planned. hubble.preferIpv6 moves to top-level preferIpv6. dnsProxy.preCache goes away in 1.21. cilium-dbg bgp commands and the local REST BGP API are deprecated in favor of the hive shell (cilium-dbg shell -- bgp/peers --format detailed). The ClusterMesh cert validity default is now one year — if you use the Helm generation mode, certs renew only when the chart re-renders, so upgrade at least once a year or switch to cronJob/certmanager modes.

Edge Cases That Will Bite Someone on Your Team

Who Should Skip This Entirely

The Verdict

Cilium Service Mesh in 1.20 is the first release where the honest answer to "do we need a separate mesh?" is "usually not." mTLS that covers same-node traffic, Gateway API with ExternalAuth and L4 routes, tiered cluster-wide policy via upstream KCNP, policy-correlated audit trails, and Hubble — all on the DaemonSet upgrade model, all on one Envoy per node.

What keeps it from being a blanket recommendation is the same thing that has always separated CNI-as-mesh from a real mesh: the L7 control plane. Cilium's L7 story is Gateway API plus CiliumNetworkPolicy — powerful, standards-aligned, and deliberately narrower than Istio's per-workload routing surface. If your platform's product is rich per-service traffic management, you will still run Istio ambient next to Cilium, in the documented coexistence mode. If your platform's product is paved roads with sane defaults — the platform-monkey thesis — then the CNI you already pay for just absorbed your mesh budget, and ztunnel's Beta label is the only reason not to start the rollout planning today.

Plan it as: transparent encryption now (stable), Gateway API north-south now (GA), L7 policy where the audit stream proves it (1.20 correlation makes this safe), ztunnel in one non-ClusterMesh cluster this quarter (Beta, metrics are finally there), and a re-evaluation of the Istio question when ztunnel goes stable and the ClusterMesh incompatibility is resolved.

Mermaid: Component Architecture

The control-plane and data-plane view of a full 1.20 mesh deployment — who talks to whom, and which components share a node:

flowchart TB
    subgraph K8s["Kubernetes API Server (v1.37)"]
        CRDs["CRDs: CiliumNetworkPolicy / CiliumClusterwideNetworkPolicy
ClusterNetworkPolicy (KCNP, v1alpha2) / CiliumNodeConfig (v2)
Gateway API v1.6.1 / CiliumEnvoyConfig"] end subgraph OPS["Platform Operators (kube-system)"] OP["cilium-operator (2 replicas)
IPAM · ztunnel enrollment reconciler · MCS-API · BGP"] HR["hubble-relay"] CM["clustermesh-apiserver
(not with ztunnel!)"] end subgraph NODE1["Node 1"] AG1["cilium agent
(eBPF programs: policy, BPF LB, TPROXY)"] EN1["cilium-envoy
(L7 + Gateway API)"] ZT1["ztunnel
(HBONE mTLS, 15008)"] P1["pod A (mtls-enabled ns)"] P2["pod B"] end subgraph NODE2["Node 2"] AG2["cilium agent"] EN2["cilium-envoy"] ZT2["ztunnel"] P3["pod C (mtls-enabled ns)"] end K8s -->|watch & reconcile| AG1 K8s -->|watch & reconcile| AG2 K8s --> OP OP -->|SPIFFE registration, spire CA mode| K8s AG1 -->|xDS| EN1 AG2 -->|xDS| EN2 AG1 -->|ZDS: pod metadata| ZT1 AG2 -->|ZDS| ZT2 P1 -->|iptables redirect in netns| ZT1 P2 -->|in-kernel L3/L4, no L7 rule| AG1 ZT1 -->|HBONE mTLS :15008| ZT2 ZT2 --> P3 AG1 -.->|flow events| HR AG2 -.->|flow events| HR CM -.->|global services, MCS-API| AG1 CM -.-> AG2

The Demo App: Complete Runnable Manifests

Everything below assumes this three-tier demo — the same payments/gateway namespaces used in the policy sections. Apply it and every command in this guide runs as written:

# demo-app.yaml — namespaces, deployments, services
apiVersion: v1
kind: Namespace
metadata:
  name: gateway
---
apiVersion: v1
kind: Namespace
metadata:
  name: payments
  labels:
    io.cilium/mtls-enabled: "true"     # ztunnel enrollment (requires ztunnel install)
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-gateway
  namespace: gateway
spec:
  replicas: 2
  selector:
    matchLabels: {app: api-gateway}
  template:
    metadata:
      labels: {app: api-gateway}
    spec:
      containers:
      - name: gateway
        image: ghcr.io/cilium/json-mock:http-v1.3.12
        ports: [{containerPort: 8080}]
        resources:
          requests: {cpu: 100m, memory: 64Mi}
          limits: {cpu: 500m, memory: 128Mi}
---
apiVersion: v1
kind: Service
metadata:
  name: api-gateway
  namespace: gateway
spec:
  selector: {app: api-gateway}
  ports: [{port: 8080, targetPort: 8080}]
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payments
  namespace: payments
spec:
  replicas: 3
  selector:
    matchLabels: {app: payments}
  template:
    metadata:
      labels: {app: payments}
    spec:
      containers:
      - name: payments
        image: ghcr.io/cilium/json-mock:http-v1.3.12
        ports: [{containerPort: 8080}]
        resources:
          requests: {cpu: 100m, memory: 64Mi}
          limits: {cpu: 500m, memory: 128Mi}
---
apiVersion: v1
kind: Service
metadata:
  name: payments
  namespace: payments
spec:
  selector: {app: payments}
  ports: [{port: 8080, targetPort: 8080}]
# Smoke test: connectivity, then prove mTLS and L7 policy on the wire
kubectl apply -f demo-app.yaml
kubectl -n gateway run test --image=cilium/cilium:1.20.2 --command -- sleep 3600
kubectl -n gateway exec test -- curl -s http://payments.payments:8080/ | head -3

# mTLS proof: capture on the source node, HBONE port 15008
kubectl -n kube-system exec -ti ds/cilium -- bash -c \
  "apt-get -y install tcpdump >/dev/null 2>&1; tcpdump -i eth0 port 15008 -c 5"

# L7 policy proof: apply the L7 policy from the previous section, then
hubble observe --namespace payments --protocol http --since 2m -o compact

Traffic Engineering: Maglev, Weights, and Traffic Distribution

Two 1.20 features turn Cilium's BPF load balancer from "evenly split" into a real traffic-engineering surface. Both matter to platform teams running canaries and maintenance windows.

Maglev weights via EndpointSlice annotations. For selectorless Services backed by manually managed EndpointSlices, annotate each slice with service.cilium.io/weight (0–65535, relative). A 70/30 canary becomes a two-line annotation change, and weight 0 is a connection-preserving drain: existing connections keep flowing, but no new ones are steered there — the clean maintenance-window primitive that mesh-free platforms have lacked:

apiVersion: v1
kind: Service
metadata:
  name: payments-stable
  namespace: payments
  annotations:
    service.cilium.io/lb-algorithm: maglev
spec:
  type: ClusterIP
  ports: [{name: http, port: 8080, protocol: TCP, targetPort: 8080}]
  # no selector: backends come from the EndpointSlices below
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: payments-stable-primary
  namespace: payments
  labels:
    kubernetes.io/service-name: payments-stable
  annotations:
    service.cilium.io/weight: "70"        # 70% of new connections
addressType: IPv4
ports: [{name: http, protocol: TCP, port: 8080}]
endpoints:
  - addresses: ["10.0.0.11"]
  - addresses: ["10.0.0.12"]
---
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: payments-stable-canary
  namespace: payments
  labels:
    kubernetes.io/service-name: payments-stable
  annotations:
    service.cilium.io/weight: "30"        # canary share; "0" = drain
addressType: IPv4
ports: [{name: http, protocol: TCP, port: 8080}]
endpoints:
  - addresses: ["10.0.1.21"]
  - addresses: ["10.0.1.22"]

Traffic distribution with the standard Kubernetes field. Cilium 1.20 honors the upstream trafficDistribution values PreferSameZone and PreferSameNode (beyond the older PreferClose) — keeping traffic local for latency and for cross-zone transfer costs, with graceful fallback when no local backend is healthy. Because it is the standard field, the same Service spec behaves consistently on any conformant implementation:

apiVersion: v1
kind: Service
metadata:
  name: payments
  namespace: payments
spec:
  trafficDistribution: PreferSameZone
  selector: {app: payments}
  ports: [{protocol: TCP, port: 8080, targetPort: 8080}]
  type: ClusterIP

The Monitoring Stack: Prometheus + Grafana for Mesh Health

Hubble metrics feed the standard Prometheus scrape of the agents and relay. The alerting rules below cover the four failure modes that actually page platform teams: agent down, endpoint regeneration storms, policy drops, and ztunnel enrollment failures:

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: cilium-mesh-alerts
  namespace: kube-system
  labels:
    release: prometheus            # match your Prometheus operator's ruleSelector
spec:
  groups:
  - name: cilium-datapath
    rules:
    - alert: CiliumAgentDown
      expr: cilium_agents_up == 0 or absent(cilium_agents_up)
      for: 5m
      labels: {severity: critical}
      annotations:
        summary: "Cilium agent is down on {{ $labels.k8s_node }}"
    - alert: CiliumEndpointRegenerationStalled
      expr: rate(cilium_endpoint_regenerations_total{outcome="failure"}[5m]) > 0.1
      for: 15m
      labels: {severity: warning}
      annotations:
        summary: "Endpoint regeneration failures on {{ $labels.k8s_node }}"
    - alert: CiliumPolicyDropsHigh
      expr: rate(hubble_drop_total[5m]) > 10
      for: 10m
      labels: {severity: warning}
      annotations:
        summary: "Sustained policy drops — possible rollout regression"
  - name: ztunnel
    rules:
    - alert: ZtunnelEnrollmentFailures
      expr: rate(cilium_operator_ztunnel_enrollment_ops_total{status="error"}[10m]) > 0
      for: 15m
      labels: {severity: warning}
      annotations:
        summary: "ztunnel namespace enrollment is failing — pods not getting mTLS"
    - alert: ZtunnelConnectionIssues
      expr: rate(ztunnel_connection_errors_total[10m]) > 0.5
      for: 10m
      labels: {severity: warning}
      annotations:
        summary: "ztunnel is failing a material share of HBONE connections"

Dashboard queries that matter for a mesh rollout (paste into any Grafana panel against the Hubble metrics):

# 1. mTLS coverage: share of flows on the HBONE port vs total east-west
sum(rate(hubble_flows_processed_total{protocol="TCP"}[5m]))

# 2. Policy verdicts over time (enforcement regressions show here first)
sum by (verdict) (rate(hubble_verdicts_total[5m]))

# 3. Config drift: unapplied ConfigMap keys = agent restart needed (new in 1.20)
cilium_configdrift_keys_total{status="pending"}

# 4. Envoy L7 load per node — the shared-proxy contention signal
sum by (k8s_node) (rate(envoy_http_downstream_rq_total[5m]))

A note on the metric names above: scrape them once and confirm the exact series names your chart version exposes before committing the rules to your alerting repo — Cilium's metric names have shifted across minors (the 1.20 upgrade guide documents several renames, e.g. per-node IPAM metrics replacing cilium_operator_ipam_ips). Treat alert names as code: version them, test them in audit, and grep the exposition endpoint (kubectl -n kube-system exec ds/cilium -- cilium-dbg metrics list) when a panel goes flat.

Troubleshooting: The Five Failures You Will Actually Hit

1. "Gateway API created the LB, but connections time out"

Symptom: the Gateway's Service gets an external IP; curls hang. Cause: L7 traffic never reaches Envoy — the node lacks the iptables/netfilter modules for TPROXY (bpf.tproxy=false default). Fix: verify CONFIG_NETFILTER_XT_TARGET_TPROXY on the node, or switch to the beta eBPF TPROXY (bpf.tproxy=true — remembering it is incompatible with netkit datapath mode).

2. "I labeled the namespace but pods aren't encrypted"

Symptom: tcpdump -i eth0 port 15008 shows nothing. Causes, in order of likelihood: (a) ztunnel not actually enabled — check kubectl -n kube-system describe cm cilium-config | grep enable-ztunnel -A2; (b) the destination namespace is not enrolled (both endpoints must be); (c) host-networked pods — they are skipped by design; (d) UDP flow — ztunnel is TCP-only. Fix: enroll both ends, re-check the StateDB table (cilium-dbg statedb dump | jq '."mtls-enrolled-namespaces"').

3. "L4 policy stopped matching after ztunnel enrollment"

Symptom: previously-working CiliumNetworkPolicies drop or allow unexpectedly on enrolled namespaces. Cause: traffic is encrypted inside the pod netns before Cilium's L4 engine sees it — policy now observes the tunnel, and only rules targeting the HBONE port (15008) behave classically. Fix: redesign the policy set for enrolled namespaces around the documented interaction, validate with hubble observe --verdict DROPPED, and use audit mode with policy correlation before enforcing.

4. "Upgrade to 1.20 broke TLSRoutes"

Symptom: TLSRoute objects disappear from kubectl get after the CRD update. Cause: the v1.6.1 Standard channel's TLSRoute CRD no longer serves v1alpha2, so existing objects are unreadable by the API server. Fix: restore from backup and install the Experimental channel TLSRoute CRD, which still includes v1alpha2. This is why the runbook backs up TLSRoutes before any CRD change.

5. "Node shows veth on a 6.8+ kernel with datapathMode=auto"

Symptom: cilium-dbg status reports Device Mode: veth [Configured: auto] on modern nodes. Cause: bpf.tproxy=true is set — auto mode deliberately reverts to veth when netkit and eBPF-TPROXY conflict. Fix: decide which feature you want: netkit (drop bpf.tproxy=true) or eBPF TPROXY (accept veth). The 1.20 behavior is documented, not a bug.

Performance: What the Datapath Actually Buys You

Claiming "eBPF is faster" without mechanisms is marketing. The measurable mechanisms in this stack:

What to measure before/after: p50/p99 of pod-to-pod TCP round trips (careful: ztunnel enrollment adds the TLS handshake to first-connection latency by design), Envoy CPU per 1k RPS of L7-selected traffic, and node boot-to-schedulable time. Baseline them on your workloads — the numbers that matter are yours.

Migrating Off Ingress NGINX: The Actual Playbook

The Cilium 1.20 release notes make an aggressive pitch: "If you are still running Ingress NGINX, now is the time to let the CNI you already run take on that traffic management too." Here is what that migration actually looks like, including the parts that hurt.

The mechanical translation

Every NGINX annotation has a Gateway API equivalent, but they are not 1:1 — a rewrite-based path routing becomes a URLRewrite filter, a TLS-secret annotation becomes certificates.refs, and canary weights become weighted backendRefs (or, for selectorless Services, the Maglev slice weights from the traffic section). The docs ship a full "Migrating from Ingress to Gateway" walkthrough with the annotation-to-field mapping tables — start there, not from scratch.

# Before: NGINX Ingress with the usual annotation pile
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: payments
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /$2
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls: [{hosts: [payments.example.com], secretName: payments-tls}]
  rules:
  - host: payments.example.com
    http:
      paths:
      - path: /v1(/|$)(.*)
        pathType: ImplementationSpecific
        backend: {service: {name: payments, port: {number: 8080}}}
---
# After: HTTPRoute on the shared Cilium Gateway
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: payments
  namespace: payments
spec:
  parentRefs: [{name: shared-gateway, namespace: gateway-system}]
  hostnames: ["payments.example.com"]
  rules:
  - matches:
    - path: {type: PathPrefix, value: /v1}
    filters:
    - type: URLRewrite
      urlRewrite:
        path: {type: ReplacePrefixMatch, replacePrefix: /}
    backendRefs: [{name: payments, port: 8080}]

What gets better

• One Envoy DaemonSet instead of ingress-controller replicas to size and upgrade
• Namespace-scoped routes — app teams own their objects, RBAC falls out naturally
• Portable manifests: same HTTPRoute runs on Envoy Gateway, Traefik, Istio
• L7 policy and ingress share one datapath and one Hubble view (drops at the edge are visible with policy correlation)

What hurts

• Every chart that templates ingressClassName: nginx and annotations needs a values refactor
• ImplementationSpecific path types must be re-decided explicitly as Exact/PathPrefix/RegularExpression
• Custom snippets / Lua / server-block configs have no Gateway API equivalent — they become separate routes, CiliumEnvoyConfig (admin-only, minimal validation), or app code
• You are now on Gateway API release cadence as well as Cilium's — track CRD versions deliberately

Rollout pattern that works: run both controllers in parallel — Gateway API's Gateway and NGINX's IngressClass can coexist — and cut over host by host via DNS weights. Keep NGINX until every annotation-driven behavior is verified on the Gateway in Hubble (the flow-level comparison between the two paths is your acceptance test), then decommission the controller and reclaim its resource requests.

References & Further Reading