| pick your distro, get ZFS on root
kldload — your platform, your way, free
Source

Cilium Masterclass

This guide goes deep on Cilium — the eBPF-native CNI that replaces kube-proxy, iptables, and traditional overlay networking with kernel programs that make routing decisions at wire speed. If you have read the Networking tutorial and the Kubernetes on KVM guide, this is the natural next step: you have a cluster, now make the networking layer invisible, unbreakable, and observable.

What this page covers: Cilium architecture, kube-proxy replacement, L3/L4/L7 network policy, Hubble observability, WireGuard transparent encryption, sidecar-free service mesh, BGP service announcement, per-pod bandwidth management, and a full troubleshooting reference — all grounded in the kldload stack you already have running.

Prerequisites: a kldload cluster built from the Kubernetes on KVM guide, or any Kubernetes cluster on a kldload node running kernel 5.14 or newer.


1. What Cilium Is

Cilium is a Kubernetes CNI plugin — but that undersells it. Traditional CNI plugins (Flannel, Calico, Weave) run in userspace daemons that program iptables rules and VXLAN tunnels. Every packet decision goes through those iptables chains. Cilium is different at the foundation: it compiles network policy into eBPF programs and loads them directly into the Linux kernel. Packet decisions happen in the kernel, before any userspace code runs, with no context switches.

What Cilium replaces

kube-proxy (service routing), iptables/nftables (policy enforcement), traditional IPVS load balancing, and in many deployments, the CNI overlay entirely. One piece of software, loaded into the kernel, handles all of it.

// iptables: userspace rule table, O(n) lookup per packet // Cilium eBPF: kernel hash map, O(1) lookup per packet

The eBPF model

Cilium compiles policy into verified eBPF bytecode and attaches it to kernel hooks — TC (traffic control), XDP (eXpress Data Path), and socket-level hooks. The kernel executes these programs at packet time. No userspace daemon in the hot path.

// Think of it as: your network policy IS the kernel // Not: a daemon that watches the kernel and reacts

Identity-based security

Instead of IP addresses, Cilium assigns a numeric security identity to each pod based on its Kubernetes labels. Policy rules reference identities, not IPs. Pods can move, scale, and reschedule — the identity follows the labels, not the address.

// IP-based policy: allow 10.0.1.5 → 10.0.1.8:8080 // Identity-based: allow app=frontend → app=backend:8080

L7 visibility

Cilium can parse HTTP, gRPC, Kafka, and DNS at the kernel level. This means you can write a policy that allows GET /api/v1/health but denies DELETE /api/v1/users — without a sidecar proxy, without modifying the application.

// This is application-layer firewalling in the kernel. // No Envoy. No service mesh. Just eBPF.
Here is why this matters at scale. iptables chains are linear. Every packet that hits a service IP walks the entire chain until it finds a matching rule. At 100 services, that chain has hundreds of rules. At 1000 services, thousands. At 5000+ services — which is not unusual in production Kubernetes — iptables programming itself takes 10–30 seconds every time a service changes. During that window, new endpoints are unreachable. The conntrack table, which iptables relies on for NAT state, is a fixed-size global table. Fill it up and connections start failing silently. Cilium eliminates all of this: eBPF maps are hash tables with O(1) lookup at any scale, there is no conntrack table for east-west traffic, and map updates are atomic — no programming window, no downtime.

2. Why Cilium on kldload

kldload already ships the eBPF toolchain — bcc, bpftrace, and the full kernel headers needed to compile eBPF programs. The kernel shipped with kldload (5.14+ on CentOS Stream 9, 6.1+ on Debian 13) has BTF (BPF Type Format) enabled, which is what Cilium needs for its CO-RE (Compile Once — Run Everywhere) programs. You are not bolting Cilium onto a foreign kernel. You are running it on a kernel built for this.

kldload feature Cilium integration What you get
WireGuard host tunnels Cilium WireGuard encryption Host-to-host AND pod-to-pod encryption, independently keyed
bcc / bpftrace Hubble flow exporter Custom eBPF probes alongside structured Kubernetes flows
FRRouting BGP Cilium BGP speaker Single BGP fabric for both host routes and service IPs
ZFS zvol nodes Fast node provisioning Cilium auto-discovers new nodes as they join; no manual CNI setup
The kldload networking stack has four layers working together. WireGuard provides encrypted transport between nodes — this is the physical-to-logical bridge. VXLAN (or direct-routing mode) provides the overlay so pod CIDRs are reachable across nodes. BGP (via FRRouting) advertises those routes so the rest of your LAN can reach pods and services. Cilium is the eBPF dataplane that sits above all of this — it programs the actual forwarding decisions that move packets from pod to pod, from pod to service, and from outside the cluster to pods, using identity-based rules instead of iptables chains. These layers are not redundant. Each does something the others cannot. WireGuard encrypts. VXLAN segments. BGP routes. Cilium enforces.

3. Install Cilium

Install the Cilium CLI

# Download and install the Cilium CLI
CILIUM_CLI_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/cilium-cli/main/stable.txt)
curl -L --fail --remote-name-all \
  https://github.com/cilium/cilium-cli/releases/download/${CILIUM_CLI_VERSION}/cilium-linux-amd64.tar.gz

tar xzvf cilium-linux-amd64.tar.gz
sudo mv cilium /usr/local/bin/
rm cilium-linux-amd64.tar.gz

# Verify
cilium version --client

Quick install (dev/test)

# Installs Cilium with sensible defaults
# kubeProxyReplacement=true removes kube-proxy entirely
cilium install \
  --set kubeProxyReplacement=true

# Watch it come up
cilium status --wait

Production install with Helm

helm repo add cilium https://helm.cilium.io/
helm repo update

helm install cilium cilium/cilium \
  --namespace kube-system \
  --set kubeProxyReplacement=true \
  --set k8sServiceHost=10.80.0.1 \
  --set k8sServicePort=6443 \
  --set ipam.mode=kubernetes \
  --set routingMode=tunnel \
  --set tunnelProtocol=vxlan \
  --set hubble.relay.enabled=true \
  --set hubble.ui.enabled=true \
  --set encryption.enabled=true \
  --set encryption.type=wireguard

If you installed kube-proxy — remove it first

# Delete the kube-proxy DaemonSet before enabling kubeProxyReplacement
kubectl -n kube-system delete daemonset kube-proxy

# Also clean up the ConfigMap
kubectl -n kube-system delete configmap kube-proxy

# Flush any iptables rules kube-proxy left behind
# Run this on every node:
iptables-save | grep -v KUBE | iptables-restore
ip6tables-save | grep -v KUBE | ip6tables-restore

Verify

# Check overall health — should show all green
cilium status

# Run the full connectivity test suite
# Creates a test namespace, deploys pods, tests all traffic paths
cilium connectivity test

# Check which nodes have the Cilium agent running
kubectl -n kube-system get pods -l k8s-app=cilium -o wide
kubeProxyReplacement=true is the key flag. Without it, Cilium runs alongside kube-proxy and only handles some of the dataplane work. With it, Cilium's eBPF programs take over 100% of service routing. kube-proxy is no longer started, its iptables rules are not created, and the conntrack overhead disappears entirely. This is the mode you want. The "partial replacement" mode exists only for compatibility with environments that cannot remove kube-proxy — on kldload, you control the full stack, so there is no reason to keep it.

4. Cilium Replaces kube-proxy

kube-proxy's job is simple to describe but expensive to implement: when a pod calls a ClusterIP service, something has to translate that virtual IP to a real pod IP. kube-proxy does this by writing iptables DNAT rules — one per endpoint, chained with probability weights for load balancing.

How kube-proxy works

# What kube-proxy writes to iptables (simplified):
# For a service with 3 endpoints:
-A KUBE-SVC-XYZ -m statistic --mode random --probability 0.333 -j KUBE-SEP-A
-A KUBE-SVC-XYZ -m statistic --mode random --probability 0.500 -j KUBE-SEP-B
-A KUBE-SVC-XYZ -j KUBE-SEP-C

-A KUBE-SEP-A -p tcp -j DNAT --to-destination 10.0.1.5:8080
-A KUBE-SEP-B -p tcp -j DNAT --to-destination 10.0.1.6:8080
-A KUBE-SEP-C -p tcp -j DNAT --to-destination 10.0.1.7:8080

# And a conntrack entry is created for every connection to track the NAT.
# At 5000 services with 10 endpoints each = 50,000+ rules to walk per packet.

How Cilium does it

# Cilium stores service endpoints in a BPF map (a kernel hash table):
# cilium bpf lb list shows the current state

# Service VIP → backend list (one lookup, O(1))
cilium bpf lb list

# Output (abbreviated):
# SERVICE ADDRESS    BACKEND ADDRESS
# 10.96.0.10:53      10.0.0.50:53 (weight 1)
# 10.96.0.10:53      10.0.0.51:53 (weight 1)
# 10.96.120.45:80    10.0.1.22:8080 (weight 1)
# 10.96.120.45:80    10.0.1.23:8080 (weight 1)

# The eBPF program attached to the socket intercepts the connect() syscall
# *before* the packet is even built, rewrites the destination to a backend IP,
# and records it in a local socket cookie. No conntrack. No NAT. No iptables.
# The kernel handles this in the socket layer — the packet that hits the NIC
# already has the correct destination.

Concrete example: pod calls a ClusterIP service

# 1. Pod calls connect("10.96.120.45", 80)
# 2. Cilium's eBPF program intercepts at the socket layer (cgroup/connect4)
# 3. BPF map lookup: 10.96.120.45:80 → [10.0.1.22:8080, 10.0.1.23:8080]
# 4. Select backend: consistent hashing (or round-robin, or maglev)
# 5. Rewrite destination: 10.96.120.45:80 → 10.0.1.22:8080
# 6. Store mapping in socket cookie (no global conntrack table entry)
# 7. Packet leaves pod with real destination. No NAT on return path.

# Compare: kube-proxy flow
# 1. Pod calls connect("10.96.120.45", 80) — kernel builds packet normally
# 2. Packet hits iptables OUTPUT chain
# 3. Walk KUBE-SERVICES → KUBE-SVC-XYZ → probability match → KUBE-SEP-A
# 4. DNAT: rewrite destination
# 5. conntrack entry created to track the NAT for return traffic
# 6. Return packet hits conntrack, gets un-DNAT'd
# Every connection = one conntrack entry. conntrack table is finite.
At 5000+ services, iptables rule programming itself becomes the bottleneck. When kube-proxy detects a service change, it rebuilds and reloads the entire iptables ruleset — an atomic swap of thousands of rules. On large clusters this takes 10–30 seconds. During that window, newly added endpoints are unreachable, and removed endpoints still receive traffic. Cilium's eBPF map updates are per-entry and atomic. Adding a new endpoint is one map insert — microseconds, not seconds. There is no programming window, no traffic black hole, no scale ceiling.

5. Network Policy with Cilium

Standard Kubernetes NetworkPolicy is L3/L4 — you can allow or deny traffic based on IP addresses, pod selectors, namespaces, and ports. Cilium supports all of that natively, and extends it to L7 (application layer) and DNS-based rules.

L3/L4 policy — standard Kubernetes NetworkPolicy

# Allow frontend pods to reach backend on port 8080
# Deny everything else to backend
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-ingress
  namespace: production
spec:
  podSelector:
    matchLabels:
      app: backend
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          app: frontend
    ports:
    - protocol: TCP
      port: 8080

L7 HTTP policy — Cilium extension

# Allow GET /api/health from frontend
# Deny POST /api/admin from everywhere
# This is a CiliumNetworkPolicy — Cilium's CRD, not the standard K8s type
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: backend-http-policy
  namespace: production
spec:
  endpointSelector:
    matchLabels:
      app: backend
  ingress:
  - fromEndpoints:
    - matchLabels:
        app: frontend
    toPorts:
    - ports:
      - port: "8080"
        protocol: TCP
      rules:
        http:
        - method: GET
          path: /api/health
        - method: GET
          path: /api/v1/.*   # regex paths supported

DNS-based egress policy

# Allow pods to reach *.example.com but nothing else on the internet
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: egress-dns-allowlist
  namespace: production
spec:
  endpointSelector:
    matchLabels:
      app: backend
  egress:
  - toFQDNs:
    - matchPattern: "*.example.com"
    - matchName: "api.github.com"
  - toEndpoints:
    - matchLabels:
        k8s:io.kubernetes.pod.namespace: kube-system
        k8s-app: kube-dns
    toPorts:
    - ports:
      - port: "53"
        protocol: ANY

Default-deny namespace policy

# Lock down an entire namespace — deny all by default
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: default-deny
  namespace: production
spec:
  endpointSelector: {}   # matches all pods in namespace
  ingress:
  - {}                   # allow nothing (no fromEndpoints = implicit deny)
  egress:
  - {}                   # allow nothing
---
# Then layer explicit allow rules on top for each service that needs them
Standard Kubernetes NetworkPolicy is L3/L4 only — you can filter by IP block, namespace, pod label, and port. That is necessary but not sufficient for real security. Knowing that frontend can reach backend on port 8080 does not tell you which HTTP methods and paths are allowed. Cilium extends this to L7: you can allow GET /api/health but deny POST /api/admin. You can allow Kafka topic reads but deny writes. You can allow DNS queries to your internal resolver but deny queries to 8.8.8.8. This is application-layer firewalling running in the kernel, with no sidecar proxy, no application changes, and no added latency from a userspace proxy hop.

6. Hubble — Network Observability

Hubble is Cilium's observability layer. It is built into the Cilium agent and exports a structured stream of every network flow between pods — with pod names, namespaces, labels, verdict (forwarded/dropped/policy-denied), and the specific policy that caused a drop. This is not pcap. It is structured metadata at the Kubernetes identity layer.

Enable Hubble

# Enable Hubble relay and UI (if not done at install time)
cilium hubble enable --ui

# Or via Helm:
helm upgrade cilium cilium/cilium \
  --namespace kube-system \
  --reuse-values \
  --set hubble.relay.enabled=true \
  --set hubble.ui.enabled=true \
  --set hubble.metrics.enabled="{dns,drop,tcp,flow,icmp,http}"

Install the Hubble CLI

HUBBLE_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/hubble/master/stable.txt)
curl -L --fail --remote-name-all \
  https://github.com/cilium/hubble/releases/download/${HUBBLE_VERSION}/hubble-linux-amd64.tar.gz

tar xzvf hubble-linux-amd64.tar.gz
sudo mv hubble /usr/local/bin/
rm hubble-linux-amd64.tar.gz

# Set up port-forward to the Hubble relay
cilium hubble port-forward &

# Verify
hubble status

Watch live flows

# All flows in the cluster — live
hubble observe

# Filter to a specific namespace
hubble observe --namespace production

# Filter to flows involving a specific pod
hubble observe --pod production/backend-7f9c8b-xkp2q

# Watch only dropped packets (policy violations, unreachable endpoints)
hubble observe --verdict DROPPED

# Watch only policy-denied flows
hubble observe --verdict POLICY_DENIED

# Trace the path of a specific request
# (shows source, destination, verdict at each hop)
hubble observe \
  --from-pod production/frontend-abc123 \
  --to-pod production/backend-xyz789 \
  --protocol TCP

Hubble UI

# Open the Hubble UI in a browser (port-forward to the UI service)
cilium hubble ui

# This opens http://localhost:12000 — a graphical network map
# showing all pods as nodes and traffic flows as edges,
# color-coded by verdict (green=forwarded, red=dropped)

Hubble metrics for Prometheus

# Hubble exports Prometheus metrics on port 9965 of each Cilium agent pod
# Key metrics:
# hubble_flows_processed_total{verdict="FORWARDED"}
# hubble_flows_processed_total{verdict="DROPPED"}
# hubble_drop_total{reason="POLICY_DENIED"}
# hubble_http_requests_total{method="GET",protocol="HTTP/1.1"}
# hubble_dns_queries_total{rcode="NOERROR"}

kubectl -n kube-system port-forward daemonset/cilium 9965:9965 &
curl http://localhost:9965/metrics | grep hubble_flows
Hubble replaces tcpdump + grep for Kubernetes network debugging. With tcpdump you capture raw packets, try to correlate IPs back to pod names, and search through binary data for the flow you care about. With Hubble you get structured records: timestamp, source pod name, source namespace, destination pod name, destination namespace, verdict, and the specific policy rule that caused a drop — all in real time. The debugging workflow changes from "capture everything and search" to "filter to the exact namespace, pod, and verdict I care about." Policy-denied flows include the policy name that blocked them. You know immediately why a packet was dropped, not just that it was.

7. Transparent Encryption (WireGuard Mode)

Cilium can encrypt all pod-to-pod traffic crossing node boundaries using WireGuard. Every Cilium agent generates a WireGuard keypair. Peers exchange public keys automatically via the Kubernetes API. No certificates, no PKI, no manual configuration. Every cross-node pod packet is encrypted without any application changes.

Enable WireGuard encryption

# Enable at install time (recommended)
helm install cilium cilium/cilium \
  --namespace kube-system \
  --set encryption.enabled=true \
  --set encryption.type=wireguard \
  --set encryption.wireguard.userspaceFallback=false

# Or enable on an existing installation
helm upgrade cilium cilium/cilium \
  --namespace kube-system \
  --reuse-values \
  --set encryption.enabled=true \
  --set encryption.type=wireguard

# Restart Cilium agents to pick up new config
kubectl -n kube-system rollout restart daemonset/cilium

Verify encryption is active

# Check Cilium status — should show "WireGuard (enabled)"
cilium status | grep Encryption

# Inspect the WireGuard interface that Cilium creates on each node
# (SSH to a node, or exec into the Cilium pod)
kubectl -n kube-system exec -ti daemonset/cilium -- cilium-dbg encrypt status

# Output shows:
# Encryption: WireGuard
# Interface: cilium_wg0
# Public key: 
# Peers:  connected

# Confirm with a tcpdump — you should see WireGuard UDP (port 51871)
# but NO readable HTTP/TCP inside those packets
tcpdump -i eth0 -n 'udp port 51871'

Node keys are managed automatically

# Cilium stores WireGuard public keys as Kubernetes node annotations
kubectl get node k8s-worker-1 -o jsonpath='{.metadata.annotations}' | jq .

# Output includes:
# "network.cilium.io/wg-pub-key": "base64pubkey=="

# When a new node joins, it reads the public keys of all existing nodes
# from these annotations and establishes WireGuard sessions automatically.
# No manual peer configuration. No key distribution problem.
On a kldload cluster where nodes already run WireGuard for host-level tunnels (the four-plane mesh from the networking tutorial), enabling Cilium's WireGuard encryption adds a second independent encryption layer specifically for pod traffic. The host WireGuard tunnel (wg0/wg1/wg2/wg3) encrypts everything between nodes at the transport level — including control plane, etcd, kubelet, and pod traffic. Cilium's encryption (cilium_wg0) encrypts pod-to-pod traffic at the network identity level, keyed per node pair, independently of the host tunnel. This is defense in depth: compromise of one layer does not expose the other. For high-security environments, this means pod traffic is double-encrypted with keys managed by different systems.

8. Service Mesh Without Sidecars

Traditional service meshes (Istio, Linkerd) inject a proxy container (Envoy or similar) into every pod. This proxy intercepts all traffic, adds mTLS, collects telemetry, and enforces retries and circuit breakers. The sidecar model works, but it is expensive: each proxy consumes 50–100 MB of RAM, 0.1–0.5 CPU cores, and adds a userspace hop to every request.

Cilium's service mesh mode does mTLS, traffic shaping, and observability in eBPF — no sidecars, no Envoy per pod, no extra memory.

Enable Cilium Service Mesh

helm upgrade cilium cilium/cilium \
  --namespace kube-system \
  --reuse-values \
  --set ingressController.enabled=true \
  --set ingressController.default=true \
  --set gatewayAPI.enabled=true

Traffic splitting (canary deployments)

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: backend-canary
  namespace: production
spec:
  parentRefs:
  - name: cilium-gateway
  rules:
  - backendRefs:
    - name: backend-stable
      port: 8080
      weight: 90
    - name: backend-canary
      port: 8080
      weight: 10

Retries and timeouts

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: backend-with-retries
  namespace: production
spec:
  parentRefs:
  - name: cilium-gateway
  rules:
  - backendRefs:
    - name: backend
      port: 8080
    filters:
    - type: ExtensionRef
      extensionRef:
        group: cilium.io
        kind: CiliumHTTPRetryPolicy
        name: standard-retries
Feature Istio (sidecars) Cilium (eBPF)
mTLS Yes, via Envoy sidecar Yes, via WireGuard encryption
Traffic splitting Yes, VirtualService CRD Yes, HTTPRoute / Gateway API
Observability Yes, via Envoy access logs Yes, via Hubble flow export
L7 policy Yes, AuthorizationPolicy Yes, CiliumNetworkPolicy HTTP rules
Memory per pod 50–100 MB (Envoy sidecar) 0 MB (no sidecar)
Added latency 0.3–1 ms (userspace proxy hop) < 10 µs (eBPF kernel hook)
Application changes None (injection is automatic) None
Sidecar service meshes add a proxy container to every pod. At 100 pods, that is 100 Envoy processes. At 50–100 MB each, that is 5–10 GB of RAM consumed purely for proxying — before any of your applications run. Each Envoy adds a userspace hop to every request: packet arrives at pod, kernel delivers to Envoy, Envoy makes a routing decision, Envoy sends to application. On the way out, the reverse. Two extra userspace context switches per request. Multiply by your request rate. Cilium does the same policy enforcement and observability in eBPF programs attached to kernel hooks — zero extra containers, zero extra memory, no userspace hop. The tradeoff is flexibility: Envoy supports protocols Cilium does not. But for HTTP, gRPC, Kafka, and DNS, Cilium's L7 support covers most workloads.

9. BGP with Cilium

Cilium has a built-in BGP speaker that can announce Kubernetes LoadBalancer service IPs to your LAN router. This replaces MetalLB. Combined with the FRRouting BGP setup from the Networking tutorial, your router learns about both kldload host subnets and Kubernetes service IPs from the same BGP session.

Enable Cilium BGP

helm upgrade cilium cilium/cilium \
  --namespace kube-system \
  --reuse-values \
  --set bgpControlPlane.enabled=true

Configure a BGP peering policy

# Tell Cilium which nodes should peer with which router
# and what ASN to use
apiVersion: cilium.io/v2alpha1
kind: CiliumBGPPeeringPolicy
metadata:
  name: bgp-peering-policy
spec:
  nodeSelector:
    matchLabels:
      kubernetes.io/os: linux    # all nodes
  virtualRouters:
  - localASN: 65001              # your cluster ASN
    exportPodCIDR: false         # don't advertise pod CIDRs (service IPs only)
    neighbors:
    - peerAddress: 192.168.1.1/32   # your LAN router
      peerASN: 65000               # your router's ASN
    serviceSelector:
      matchExpressions:
      - key: somekey
        operator: NotIn
        values:
        - never-select             # match all services

Assign an IP pool for LoadBalancer services

# Define a pool of IPs that Cilium can assign to LoadBalancer services
# These IPs will be announced via BGP
apiVersion: cilium.io/v2alpha1
kind: CiliumLoadBalancerIPPool
metadata:
  name: lan-pool
spec:
  cidrs:
  - cidr: 192.168.10.0/24    # a dedicated range on your LAN
  serviceSelector:
    matchLabels:
      announce: bgp

Create a service and watch it get announced

apiVersion: v1
kind: Service
metadata:
  name: my-app
  labels:
    announce: bgp    # matches the pool selector above
spec:
  type: LoadBalancer
  selector:
    app: my-app
  ports:
  - port: 80
    targetPort: 8080
# Watch Cilium assign an IP and announce it
kubectl get service my-app -w

# Check BGP session status
kubectl get ciliumbgppeeringstatus -A

# Verify the route appeared on your router
# (on your FRRouting instance or LAN router)
vtysh -c 'show ip bgp summary'
vtysh -c 'show ip route bgp'
This closes the loop from the networking tutorial. Your kldload node runs FRRouting for host-level BGP — advertising its own subnets and WireGuard addresses to your LAN router. Cilium's BGP speaker adds Kubernetes service IPs to that same BGP session. From your router's perspective, it has one BGP neighbor (or a handful) that tells it about both the host networks and the Kubernetes services. You can reach any LoadBalancer service from any machine on your LAN without manual routes, without NodePort port forwarding, and without an external load balancer. The IP assigned to the service is a real LAN IP. DNS resolves it. Firewall rules apply to it. It is just a regular IP — one that happens to be backed by a Kubernetes deployment that you can scale, roll, and snapshot.

10. Bandwidth Manager & Rate Limiting

Cilium's Bandwidth Manager enforces per-pod egress bandwidth limits using eBPF and the kernel's EDT (Earliest Departure Time) mechanism. Unlike tc rate limiting or CNI chaining hacks, this is a single eBPF program attached to each pod's network interface — zero overhead when there is no limit, precise enforcement when there is.

Enable the Bandwidth Manager

helm upgrade cilium cilium/cilium \
  --namespace kube-system \
  --reuse-values \
  --set bandwidthManager.enabled=true \
  --set bandwidthManager.bbr=true    # use BBR congestion control (recommended)

Set per-pod limits via annotations

apiVersion: v1
kind: Pod
metadata:
  name: rate-limited-app
  annotations:
    # Egress bandwidth limit for this pod
    kubernetes.io/egress-bandwidth: "100M"
    # Ingress bandwidth limit
    kubernetes.io/ingress-bandwidth: "100M"
spec:
  containers:
  - name: app
    image: my-app:latest

Verify bandwidth enforcement

# Check that the bandwidth manager is active
cilium status | grep Bandwidth

# Inspect the eBPF programs attached to a pod's interface
# (get the pod's veth interface name first)
POD_IFACE=$(kubectl get pod rate-limited-app -o jsonpath='{.status.podIP}' | \
  xargs -I{} ip r get {} | awk '{print $NF}')

# View TC programs attached to that interface
tc filter show dev $POD_IFACE egress

# Run an iperf3 test to verify the limit
kubectl run iperf-server --image=networkstatic/iperf3 -- -s
kubectl run iperf-client --image=networkstatic/iperf3 \
  --annotations='kubernetes.io/egress-bandwidth=50M' \
  -- -c $(kubectl get pod iperf-server -o jsonpath='{.status.podIP}') -t 10

11. Troubleshooting

Basic health check

# Overall Cilium health — first thing to check
cilium status

# Run the full connectivity test (creates a test namespace)
cilium connectivity test

# Check agent logs on a specific node
kubectl -n kube-system logs daemonset/cilium --previous 2>&1 | tail -100

# Get a shell in the Cilium agent pod for deeper inspection
kubectl -n kube-system exec -ti daemonset/cilium -- bash

Hubble for live debugging

# Watch all flows in real time — look for DROPPED or POLICY_DENIED
hubble observe --verdict DROPPED --follow

# Trace a specific pod's traffic
hubble observe --pod production/my-pod --follow

# Check DNS resolution for a pod
hubble observe --pod production/my-pod --protocol DNS

# See which policy is dropping traffic
hubble observe --verdict POLICY_DENIED -o json | jq '.flow.drop_reason_desc'

eBPF map inspection

# List all services in the BPF load balancer map
cilium bpf lb list

# Show the identity map (pod labels → numeric security identity)
cilium bpf identity list

# Show endpoint policy map for a specific endpoint
cilium bpf policy get

# Show nat table (should be empty or minimal with kubeProxyReplacement=true)
cilium bpf nat list

# Show connection tracking table
cilium bpf ct list global

Common issues

Symptom Likely cause Fix
Pods can't reach services kube-proxy still running alongside Cilium Delete kube-proxy DaemonSet, flush iptables
Cilium agent CrashLoopBackOff Kernel too old or BTF not enabled kldload ships 5.14+ with BTF; check ls /sys/kernel/btf/vmlinux
BPF filesystem not mounted BPF FS not in fstab mount bpffs /sys/fs/bpf -t bpf and add to fstab
Policy drops unexpected traffic Default-deny policy active hubble observe --verdict POLICY_DENIED to find the rule
WireGuard encryption not working cilium_wg0 interface missing Check ip link show cilium_wg0 on each node; check agent logs
BGP not announcing service IPs Service missing announce: bgp label or pool CIDR mismatch kubectl get ciliumbgppeeringstatus and check pool selector

Kernel requirements (kldload is fine)

# Verify your kernel meets Cilium's requirements
# kldload ships kernels that pass all of these

# Minimum: 4.19 for basic eBPF
# Recommended: 5.10+ for full feature set
# kube-proxy replacement: 5.8+
# WireGuard encryption: 5.6+
# BBR bandwidth manager: 5.8+
uname -r

# Check BTF support (required for CO-RE, present on all kldload kernels)
ls /sys/kernel/btf/vmlinux

# Check BPF filesystem
mount | grep bpf

# Check that the required kernel config options are enabled
zcat /proc/config.gz | grep -E 'CONFIG_BPF|CONFIG_WIREGUARD'

12. The Full kldload Networking Stack

Putting it all together: every layer of the kldload networking stack has a specific job. None of them overlap. None of them replace each other. They compose.

Layer Technology Job Configured by
Physical eth0 / bond0 Move bits between machines NetworkManager / systemd-networkd
Encrypted transport WireGuard (wg0–wg3) Encrypt all node-to-node traffic; segment planes (mgmt/storage/data) kldload WireGuard config / kldload-wg tool
Overlay VXLAN (Cilium tunnel) Bridge pod CIDRs across nodes; pods see a flat L2 network Cilium (tunnel mode)
Routing BGP via FRRouting Advertise host subnets and K8s service IPs to LAN FRRouting + CiliumBGPPeeringPolicy
Pod dataplane Cilium eBPF Service routing, identity-based policy, L7 filtering, load balancing Cilium / CiliumNetworkPolicy CRDs
Pod encryption Cilium WireGuard (cilium_wg0) Encrypt pod-to-pod cross-node traffic independently of host tunnels Cilium encryption config
Observability Hubble Structured flow export, policy drop visibility, Prometheus metrics Hubble relay + UI (deployed with Cilium)
Custom eBPF bcc / bpftrace Ad-hoc kernel tracing alongside Cilium's programs kldload ships bcc/bpftrace pre-installed

The complete picture: a packet from your laptop to a Kubernetes pod travels through your LAN switch, arrives at the kldload node's physical NIC, enters a WireGuard tunnel that encrypts it for transport, gets decapsulated on the remote node, hits Cilium's eBPF program which checks the security identity, validates L7 policy if applicable, re-encrypts it via Cilium's WireGuard layer for the final pod-to-pod hop, and arrives at the pod. Zero iptables. Zero conntrack for east-west traffic. Zero sidecars. The entire network stack runs in the kernel.

Your LAN router knows the pod's service IP because Cilium's BGP speaker advertised it. Your monitoring stack sees every flow because Hubble exported it. Your security team can audit every allowed and denied connection because the policy is stored as CRDs in the Kubernetes API. This is what a modern kernel-native networking stack looks like.

Related pages