Deploy MetalLB Load Balancer on Bare-Metal Kubernetes¶
On cloud-managed Kubernetes (EKS, GKE, AKS), creating a Service of type LoadBalancer automatically provisions a cloud load balancer and assigns a stable external IP. On bare-metal kubeadm clusters, no such integration exists — the Service stays in <pending> for EXTERNAL-IP forever.
MetalLB fills this gap. It is a load balancer implementation for bare-metal clusters that watches for LoadBalancer Services and assigns IP addresses from a pool we define. This is what enables ingress-nginx to receive a real IP on bare-metal instead of a random NodePort.
Official Documentation
How MetalLB Works¶
MetalLB deploys two components into the metallb-system namespace:
| Component | Kind | Role |
|---|---|---|
controller | Deployment | Watches Services; assigns IPs from the pool |
speaker | DaemonSet | Runs on every node; announces IPs to the network |
MetalLB supports two announcement protocols. Choose based on the network:
| Mode | How It Works | Best For |
|---|---|---|
| Layer 2 (L2) | Speaker responds to ARP requests for the assigned IP | Home labs, simple bare-metal, single subnet |
| BGP | Speaker peers with the router via BGP and advertises the IP | Data centers, multi-subnet, production HA |
This runbook covers Layer 2 mode
L2 mode requires zero router configuration and works on any flat network. BGP mode requires a router that speaks BGP and is beyond the scope of this runbook.
L2 limitation: Only one node handles traffic for a given IP at a time (the node whose speaker won the ARP election). Failover is automatic but not instant — clients may see a brief interruption (~10s) during node failure.
Prerequisites¶
- A running kubeadm bare-metal cluster
kubectlconfigured and all nodesReady- A range of unused IP addresses on the local subnet to give to MetalLB (these must not be assigned to any node or used by the DHCP server)
ingress-nginxalready deployed (seedeploy-ingress-nginx-controller.md)
Find the subnet range before proceeding
Run ip addr show on any cluster node to find the subnet (e.g., 192.168.1.0/24). Then pick a small range of IPs that are outside the DHCP lease range. Example: if DHCP assigns .100–.200, it can safely use 192.168.1.240–192.168.1.250 for MetalLB.
Step 1 — Enable Strict ARP (If Using IPVS Mode)¶
Skip this step if the cluster uses the default iptables kube-proxy mode. Only required if kube-proxy is configured in IPVS mode.
If the output shows mode: "ipvs", enable strict ARP:
kubectl get configmap kube-proxy -n kube-system -o yaml | \
sed -e "s/strictARP: false/strictARP: true/" | \
kubectl apply -f - -n kube-system
Step 2 — Deploy MetalLB¶
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.15.3/config/manifests/metallb-native.yaml
This creates the metallb-system namespace and deploys the controller Deployment and speaker DaemonSet.
Note
The installation manifest deploys MetalLB components but leaves them completely idle. No IPs are assigned, no traffic is handled until applying the configuration CRs in the next steps.
Verify both components are running:
Expected output:
NAME READY STATUS RESTARTS
controller-xxxxxxxxxx-xxxxx 1/1 Running 0
speaker-xxxxx 1/1 Running 0
speaker-yyyyy 1/1 Running 0 # one per node
Step 3 — Define an IP Address Pool¶
Create an IPAddressPool CR that tells MetalLB which IPs it is allowed to assign to LoadBalancer Services:
# metallb-ipaddresspool.yaml
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: first-pool
namespace: metallb-system
spec:
addresses:
- <start-ip>-<end-ip> # e.g., 192.168.1.240-192.168.1.250
Multiple ranges are supported
Multiple CIDR blocks or ranges can define in a single pool:
Step 4 — Configure L2 Advertisement¶
Create an L2Advertisement CR to tell MetalLB to announce the IPs from the pool using Layer 2 / ARP:
# metallb-l2advertisement.yaml
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: l2-advert
namespace: metallb-system
spec:
ipAddressPools:
- first-pool
Note
Omitting spec.ipAddressPools causes MetalLB to advertise IPs from all pools via L2. Specifying the pool name explicitly is a safer practice when there are multiple pools with different purposes.
Step 5 — Verify MetalLB Assigned an IP to ingress-nginx¶
Expected output (before MetalLB):
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
ingress-nginx-controller LoadBalancer 10.96.x.x <pending> 80:3xxxx/TCP
Expected output (after MetalLB):
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
ingress-nginx-controller LoadBalancer 10.96.x.x 192.168.1.240 80:3xxxx/TCP,443:3xxxx/TCP
Success
Once EXTERNAL-IP shows a real IP from the pool, MetalLB is working. All HTTP/HTTPS traffic sent to 192.168.1.240 on the local network will now reach the ingress-nginx controller directly on ports 80 and 443.
Troubleshooting¶
EXTERNAL-IP stays <pending> after applying CRs¶
Check for errors like no available IPs (pool exhausted) or no IPAddressPool matches (pool selector mismatch).
Speaker pod is not running on a node¶
The speaker runs as a privileged DaemonSet. If Pod Security Admission is enforced, the metallb-system namespace must be labeled:
kubectl label namespace metallb-system \
pod-security.kubernetes.io/enforce=privileged \
pod-security.kubernetes.io/audit=privileged \
pod-security.kubernetes.io/warn=privileged
IP is assigned but traffic does not reach the Service¶
Verify the ARP entry on the local machine:
If missing, check that the router/switch is on the same L2 segment as the cluster nodes and is not blocking ARP.
MetalLB does not work on most cloud providers
Cloud VMs (AWS EC2, GCP, Azure) use SDN that blocks the ARP announcements MetalLB relies on in L2 mode. Use the cloud provider's native LoadBalancer integration instead. See MetalLB Cloud Compatibility.