Lesson 11 of 32 · Level 2 — Operator
Bare-metal load balancing with MetalLB
Give LoadBalancer Services real external IPs on bare metal and VMs with MetalLB: how L2 (ARP) and BGP modes work, installing it, address pools and advertisements, a kind lab, and the trade-offs against kube-vip, Cilium and hardware load balancers.
The missing piece on bare metal
On a cloud, type: LoadBalancer makes the cloud controller create a load balancer with a public IP. On bare metal or plain VMs, nothing does that: the Service stays EXTERNAL-IP <pending>, and your ingress controller (lesson 05) has no address.
MetalLB fills the gap. It watches LoadBalancer Services, assigns an IP from pools you define, and announces that IP on your network so traffic reaches the cluster.
A shop in a new street has no address yet, so no one can find it. MetalLB is the town office that gives each shop an address from a list (the IP pool), and then either shouts "that's me!" when someone asks the street for that address (L2/ARP), or tells the postal service's route map where to deliver (BGP).
Two components, two modes
- controller (Deployment): assigns IPs from
IPAddressPools to Services. - speaker (DaemonSet): announces assigned IPs from the nodes.
| L2 mode (ARP/NDP) | BGP mode | |
|---|---|---|
| How | One node answers ARP for each IP | Nodes advertise /32 routes to routers |
| Load spreading | No: one node per IP receives traffic (then kube-proxy spreads to pods) | Yes: routers use ECMP across announcing nodes |
| Failover | Another node takes over after the leader is lost (seconds) | Router withdraws routes from failed node |
| Network needs | IPs in the nodes' L2 subnet | BGP-capable routers + configuration |
| Best for | Labs, small clusters, simple networks | Production data centres |
Install
$ helm repo add metallb https://metallb.github.io/metallb
$ helm install metallb metallb/metallb -n metallb-system --create-namespace
$ kubectl -n metallb-system get pods
If kube-proxy runs in IPVS mode, enable strictARP: true in its configuration first (MetalLB's docs show how); iptables mode needs no change.
L2 configuration
Pick addresses in the nodes' subnet that your DHCP server won't hand out:
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: lan-pool
namespace: metallb-system
spec:
addresses:
- 10.0.0.240-10.0.0.250
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: lan
namespace: metallb-system
spec:
ipAddressPools: [ lan-pool ]
BGP configuration (outline)
apiVersion: metallb.io/v1beta2
kind: BGPPeer
metadata:
name: tor-a
namespace: metallb-system
spec:
myASN: 64512
peerASN: 64513
peerAddress: 10.0.0.1
---
apiVersion: metallb.io/v1beta1
kind: BGPAdvertisement
metadata:
name: dc
namespace: metallb-system
spec:
ipAddressPools: [ dc-pool ]
Agree ASNs, peer addresses, route filters and pools with the network team. (API versions of MetalLB resources change occasionally; check the docs for your release.)
Lab: MetalLB on kind (Linux host)
kind nodes are containers on the Docker network kind; carve a few IPs from its subnet:
$ docker network inspect kind -f '{{(index .IPAM.Config 0).Subnet}}'
172.18.0.0/16
Use e.g. 172.18.255.200-172.18.255.250 as the pool (IPv4 subnet may be the second entry on some setups; use the IPv4 one). Apply the L2 config above with that range, then:
$ kubectl create deployment web --image=nginx --replicas=2
$ kubectl expose deployment web --port 80 --type LoadBalancer
$ kubectl get svc web
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
web LoadBalancer 10.96.88.12 172.18.255.200 80:31234/TCP
$ curl -s http://172.18.255.200 | head -4
On macOS and Windows, Docker runs in a VM, so these IPs aren't reachable from the host directly; run the curl from another container on the kind network, or use a Linux VM.
Now switch your ingress controller Service (lesson 05) to LoadBalancer: it gets a stable IP from the pool, and all HTTP apps share it.
Alternatives
| Option | Notes |
|---|---|
| kube-vip | Control-plane VIP and Service LoadBalancer IPs (ARP or BGP); common with kubeadm, EKS Anywhere and RKE2 |
| Cilium LB IPAM + L2 announcements / BGP control plane | If you already run Cilium |
| Hardware/virtual LB (F5, HAProxy, cloud LB) | In front of NodePorts; existing enterprise standard |
| OpenStack Octavia | On OpenStack, via the cloud controller (see OpenStack Private Cloud, lesson 09) |
Try it: pools, failover and ingress
- Install MetalLB on a kind (Linux) or kubeadm cluster and create the L2 pool.
- Expose a Deployment as LoadBalancer and reach its EXTERNAL-IP.
- Find which node announces the IP (speaker logs), stop that node's container/VM, and measure how long until traffic flows again.
- Request a specific address with
metallb.io/loadBalancerIPs: 172.18.255.210as a Service annotation (check the annotation name for your version). - Switch the ingress-nginx Service to LoadBalancer and point a DNS/hosts entry at its new IP.
Going deeper: MetalLB in production
- Split pools by purpose (public ingress, internal services) and restrict which namespaces/Services may use each (pool selectors,
autoAssign: false). - With
externalTrafficPolicy: Local, only nodes running ready pods announce (BGP) or can be elected (L2), preserving client IPs; check the behaviour for your mode. - Monitor speaker/controller metrics and BGP session state; a dropped BGP session silently removes routes.
Recap
- Bare metal has no built-in LoadBalancer; MetalLB assigns IPs from IPAddressPools and announces them.
- L2 mode: one node answers ARP per IP (failover, no spreading); BGP mode: routers spread traffic with ECMP.
- Configure pools + advertisements; enable strictARP for IPVS kube-proxy.
- Give your ingress controller a MetalLB IP so many apps share one stable address.
This site is a public version of my personal engineering knowledge hub. It intentionally excludes confidential company information and internal operational details.