k8s on bare-metal - Part 2 : CNI
Part two of the bare-metal series. Part one left three control-plane nodes running and deliberately NotReady, because the machine config said cni: none. This piece gives them a network: Cilium, in eBPF, with native routing advertised over BGP, WireGuard on the wire, kube-proxy deleted, and LoadBalancer IPs handed out by the same BGP that carries the API VIP. The moment it lands, the nodes turn Ready.
A Kubernetes cluster with no CNI is a strange, quiet thing: the control plane is up, etcd is healthy, the apiserver answers, and every node reports NotReady because nothing can route a packet between two pods. That is exactly where part one stopped, on purpose, with a seam cut for the network to slot into. This piece fills it with Cilium, and on bare metal that means making a few decisions the cloud usually hides: how a pod packet actually crosses the wire, how the physical fabric learns where pods live, and what replaces kube-proxy. The reward for getting them right is a network with no overlay tax, no iptables, encrypted node to node, that hands the cluster its external IPs from the same BGP sessions it uses for everything else.
Still NotReady
Cilium is not just an add-on here; it is the first workload the cluster ever runs. Nothing else can go on before it, not even the GitOps controller, because a pod cannot be scheduled onto a node that has no network, and Argo CD is made of pods. So the CNI is installed by the Cluster API add-on provider the moment the control plane exists, and only once it turns the nodes Ready does the rest of the platform become possible. That ordering is the spine of this article, and the last section comes back to it.
Where these nodes came from: Metal3, Talos, and Cluster API turning powered-off servers into a control plane, ending exactly here, at NotReady.
How a packet crosses
Every other decision follows from one: does a pod packet travel as itself, or wrapped in an overlay? Native routing sends it unchanged and asks the physical fabric to route it; tunnel mode wraps it so the fabric can stay ignorant. On a cloud you never see this choice. On bare metal with a fabric you control, it is the difference between paying an encapsulation tax on every packet or not. Toggle the two:
- Packet path
- The pod packet leaves the node unchanged, with its real pod IP as the source, and the fabric routes it like any other address.
- Overhead
- None. Full 9000-byte frames, no encapsulation header, no per-packet CPU to wrap and unwrap.
- What the fabric must know
- The switches must know how to reach pod CIDRs. That is exactly what BGP is for: every node advertises its PodCIDR to its top-of-rack switch, so the fabric learns the routes.
- Choose it when
- You control the fabric and can peer BGP, and you want the best throughput and latency. On a leaf-spine with BGP already in play for the API VIP and service IPs, advertising pod routes over the same session is nearly free.
Speaking eBGP to the rack
Native routing only works if the switches know how to reach pods, and the clean way to tell them is BGP. Cilium has a BGP control plane built in, so every node speaks eBGP directly to its own top-of-rack switch, with a unique ASN per rack and no route reflector in the middle. Each node advertises its slice of the pod address space, and later its LoadBalancer service IPs, up into the fabric. These are the same sessions that already carry the API VIP from part one, so pod routing costs no new infrastructure, just more routes on a session that exists.
# One config per rack: nodes in rack 1 run AS 65001, peering to their ToR.
apiVersion: cilium.io/v2alpha1
kind: CiliumBGPClusterConfig
metadata:
name: rack-1
spec:
nodeSelector:
matchLabels: { rack: "1" }
bgpInstances:
- name: rack-1
localASN: 65001 # unique per rack
peers:
- name: tor-1
peerASN: 65101 # the ToR's ASN: eBGP, so it differs
peerAddress: 10.0.0.1 # the top-of-rack switch
peerConfigRef: { name: tor }
---
apiVersion: cilium.io/v2alpha1
kind: CiliumBGPAdvertisement
metadata:
name: pods-and-services
labels: { advertise: bgp }
spec:
advertisements:
- advertisementType: PodCIDR # native routing needs this
- advertisementType: Service
service: { addresses: [LoadBalancerIP] } # and the service IPs
selector:
matchLabels: { advertise: bgp }No kube-proxy, no iptables
With the fabric routing pods, kube-proxy can go. Cilium replaces it entirely: service load balancing moves into the eBPF datapath, so there are no iptables chains whose length grows with every Service you add. A request to a LoadBalancer lands on any node by ECMP, the kernel picks a backend with Maglev consistent hashing, and Direct Server Return sends the reply straight back to the client without a return-path hairpin and without rewriting the source IP. Follow one request through:
graph TD
C["Client"] -->|1. dst = service IP<br/>learned via BGP| F["Fabric / ToR<br/>ECMP across nodes"]
F -->|2. lands on any node| N["Node<br/>eBPF datapath"]
N -->|3. Maglev picks a backend<br/>no kube-proxy, no iptables| P["Backend pod<br/>maybe another node"]
P -->|4. DSR: reply goes<br/>straight to the client| CEverything switched on
An install is a set of decisions, not a wall of YAML. Here is the set, each with the switch behind it: native routing plus the BGP control plane for the datapath, full kube-proxy replacement with DSR and Maglev, cluster-pool IPAM so Cilium owns pod addressing, WireGuard for encryption, a host firewall, and Hubble for visibility. Two are worth dwelling on. WireGuard is one flag and genuinely encrypts pod traffic on the wire, and because the fabric runs jumbo 9000 frames, the pod MTU is simply reduced by WireGuard's overhead rather than fragmenting anything. And because kube-proxy is gone, the agents cannot rely on it to reach the apiserver, so they are pointed at KubePrism, Talos's built-in local API proxy on every node, rather than at the external VIP, which keeps the datapath from ever depending on the VIP being reachable. Tap through the switches:
# The load-bearing Helm values (via the add-on provider, see below).
kubeProxyReplacement: true
cgroup: { autoMount: { enabled: false }, hostRoot: /sys/fs/cgroup } # Talos already mounts cgroupv2 + bpffs
routingMode: native
ipv4NativeRoutingCIDR: 10.244.0.0/16
autoDirectNodeRoutes: false # BGP carries the routes, not L2 direct routes
ipam:
mode: cluster-pool
operator:
clusterPoolIPv4PodCIDRList: ["10.244.0.0/16"]
clusterPoolIPv4MaskSize: 24 # a /24 per node, the CIDR it advertises
bgpControlPlane: { enabled: true }
loadBalancer:
mode: dsr # hybrid is the safe fallback under strict uRPF
algorithm: maglev
encryption: { enabled: true, type: wireguard }
mtu: 8920 # 9000 fabric minus WireGuard overhead
bpf: { masquerade: true, hostLegacyRouting: false } # needs Talos forwardKubeDNSToHost: false (below)
hostFirewall: { enabled: true }
hubble:
relay: { enabled: true }
ui: { enabled: true }
k8sServiceHost: localhost # KubePrism: Talos's local API proxy, not the VIP
k8sServicePort: 7445routingMode: nativePod packets travel unencapsulated; the fabric routes them by their real IPs. No overlay, no per-packet overhead. Depends on BGP advertising the pod CIDRs, which is the next feature.
Tuned for Talos
Cilium on an immutable OS needs a handful of settings that are easy to miss and painful to debug, because Talos is not a normal Linux host. It already provides the cgroupv2 and bpffs mounts, so Cilium must be told not to mount them itself (cgroup.autoMount.enabled: false), or the agent ends up fighting the operating system. Talos also refuses to let workloads load kernel modules, so the SYS_MODULE capability is dropped from the agent and anything Cilium needs is loaded by the machine config instead. And the apiserver endpoint is KubePrism rather than the VIP: Talos runs a local, load-balanced API proxy on every node at localhost:7445, so a kube-proxy-free Cilium reaches the control plane through it and never depends on the external VIP being up.
One known issue is worth naming because this exact build walks into it. Talos forwards cluster DNS to the host by default, and that does not get along with Cilium's eBPF masquerade; CoreDNS starts misbehaving in ways that are miserable to trace back. The fix is a single machine-config line, forwardKubeDNSToHost: false, and it comes right. A note for later in the series, too: when Multus is added for the storage network, Cilium must run with cni.exclusive: false so it does not claim the CNI configuration directory as its own.
Encryption that actually encrypts
It is worth being precise about what WireGuard here does, because Cilium's security surface has a famous trap. This is transparent, node-to-node encryption of the actual pod payload: turn it on and traffic between nodes is ciphertext on the wire. That is a different guarantee from Cilium's mutual-authentication feature, which authenticates identities without necessarily encrypting the data, a distinction subtle enough to have fooled people who thought they had mTLS and did not. For a fleet that spans racks, or one where compliance asks the plain question of whether traffic is encrypted in transit, WireGuard answers it in one line.
The companion cautionary tale: how a feature branded mTLS turned out to be mutual authentication without encryption, and why the difference is the whole point.
Locking the host
The same eBPF that routes pods can firewall the nodes. With the host firewall enabled, a CiliumClusterwideNetworkPolicy becomes the node's real ingress filter, and the only open ports are the ones named: the apiserver, the Talos API, etcd between control-plane nodes, BGP to the ToR, WireGuard, the kubelet, and Hubble. The one rule of rolling this out safely is to start in audit mode, where violations are logged rather than dropped, watch what a normal day actually needs, and only then switch to enforce. Locking a node's firewall while blind is how you fence yourself out of your own cluster.
apiVersion: cilium.io/v2
kind: CiliumClusterwideNetworkPolicy
metadata:
name: host-firewall
spec:
nodeSelector: {} # every node
enableDefaultDeny:
ingress: true # flip this on only after audit mode is clean
ingress:
- fromEntities: [remote-node, health] # nodes and health checks talk freely
- toPorts:
- ports:
- { port: "6443" } # kube-apiserver
- { port: "50000" } # talos apid
- { port: "2379" } # etcd (control-plane only, tighten by selector)
- { port: "179" } # BGP to the ToR
- { port: "51871" } # WireGuard (Cilium default)
- { port: "10250" } # kubeletExternal IPs at last
This is the third of the three address layers from part one coming live: service LoadBalancer IPs. A CiliumLoadBalancerIPPool defines the routable range, any Service of type LoadBalancer draws from it automatically, and the BGP advertisement from earlier carries that IP into the fabric so it is reachable from outside the cluster. No MetalLB, no second BGP speaker, no cloud. The IP a Service gets is announced by the same Cilium that routes its pods.
apiVersion: cilium.io/v2alpha1
kind: CiliumLoadBalancerIPPool
metadata:
name: prod
spec:
blocks:
- cidr: 10.0.10.0/24 # routable, advertised via BGP
---
apiVersion: v1
kind: Service
metadata:
name: web
labels: { advertise: bgp } # matches the BGP advertisement selector
spec:
type: LoadBalancer # gets 10.0.10.x, announced to the ToR
selector: { app: web }
ports:
- { port: 443, targetPort: 8443 }Installed before everything
Back to the ordering, because it is the part that makes the whole platform bootstrap cleanly. Cilium is delivered by the Cluster API add-on provider as a HelmChartProxy that targets the cluster the instant it is created. The add-on provider watches for clusters with a label and lays the chart down before anything else is scheduled, which is the only way to break the deadlock: the CNI cannot wait for GitOps, because GitOps cannot run without the CNI. Once Cilium is in and the nodes go Ready, Argo CD can be installed the same way, and from there the platform installs itself.
apiVersion: addons.cluster.x-k8s.io/v1alpha1
kind: HelmChartProxy
metadata:
name: cilium
spec:
clusterSelector:
matchLabels: { cni: cilium } # every cluster labelled this gets Cilium, first
repoURL: https://helm.cilium.io/
chartName: cilium
namespace: kube-system
valuesTemplate: |
# the values from earlier, templated per clustergraph TD
A["Control plane up, nodes NotReady<br/>(end of part one)"] --> B["CAPI add-on provider applies<br/>a Cilium HelmChartProxy"]
B --> C["cilium-agent DaemonSet starts<br/>on every node"]
C --> D["CNI ready: kubelet flips<br/>the nodes to Ready"]
D --> E["eBGP sessions come up to the ToRs"]
E --> F["PodCIDRs + LoadBalancer IPs<br/>advertised into the fabric"]
F --> G["cluster is now a place<br/>Argo CD can be installed"]Ready
Apply it and the nodes that sat NotReady at the end of part one turn Ready within a minute: pods route to each other natively across the fabric, every hop between nodes is WireGuard ciphertext, kube-proxy is nowhere to be found, and a Service of type LoadBalancer gets a real IP that the top-of-rack switches already know how to reach. The cluster is now a functioning network that happens to run on metal you can touch. What it does not yet have is a way to install anything without a human running helm, which is the next piece: part three hands the cluster its GitOps brain, Argo CD bootstrapped by the same add-on provider, then driving everything else through ApplicationSets.
Give the cluster a brain: Argo CD installed by the add-on provider and then managing itself, delivering the platform and a full develop-to-promote loop from git.